Skip to content

refactor(core-web): TS strict mode across 30 projects — template-builder, edit-ema-ui, block-editor, ui, data-access, sdk-*, portlets - #36957

Open
nicobytes wants to merge 99 commits into
mainfrom
35932-enable-strict-mode
Open

refactor(core-web): TS strict mode across 30 projects — template-builder, edit-ema-ui, block-editor, ui, data-access, sdk-*, portlets#36957
nicobytes wants to merge 99 commits into
mainfrom
35932-enable-strict-mode

Conversation

@nicobytes

@nicobytes nicobytes commented Aug 7, 2026

Copy link
Copy Markdown
Member

What

Thirty-three steps of the strict-mode rollout (epic #35932), plus groundwork on one more (dotcms-webcomponents, not closed):

Issue Project Change
#35974 edit-content 101 + 208 errors. The spec config had opt-outs hiding 666 of them. Most fixes were declarations lagging behind bodies already written for null
#35976 dotcms-binary-field-builder Had none of the six flags. Its own tsconfig compiles nothing (include: []), so measuring that file reported a fake zero
#35967 portlets-dot-query-tool-portlet Two flags missing. Its noImplicitReturns-without-strict combination caught a TS7030 I introduced in edit-content
#35971 edit-ema-ui 119 errors. Two stores declared their pre-render state non-nullable; a spec caught me changing a wire payload
#35958 template-builder 141 errors, plus 27 that came from a fixtures file being compiled as production code
#35973 dotcms-block-editor Three tsconfigs had never type-checked anything (TS2688). Unmasking them surfaced a template defect that only a build can see
#35955 block-editor 442 own errors. Four wrong declarations explained most of them, including an Omit that silently erased every member of PrimeNG's MenuItem
#35970 dotcdn 19 errors, 8 of them from one missing switch default. First app to go strict — surfaced template errors in libs/ui
#35969 portlets-dot-usage One fixture field, plus a global fix for the htmldiff-js type leak
#35968 portlets-dot-tags-portlet 18 of 22 were Signal↔jest.Mock casts; one catch binding
#35965 portlets-dot-locales-portlet Flags were present but unsatisfied — two wrong DynamicDialogRef annotations, of(null) for Observable<void>
#35963 portlets-dot-es-search-portlet All 28 spec errors were jest.fn() assigned onto signal-typed members
#35962 portlets-dot-categories-portlet Fixture drift in two directions plus the signal-mock pattern
#35960 edit-content-bridge Dialog ref captured in a local; a control-flow blind spot in its spec
#35954 portlets-dot-analytics-data-access Compliant — its one error lived in global-store's barrel
#35952 portlets-dot-experiments-data-access Already compliant, no diff
#35951 global-store One-line export type that also closed #35954
#35950 portlets-dot-locales-data-access Already compliant, no diff
#35949 sdk-experiments Flags-only; enforcement proved by negative test
#35966 portlets-dot-plugins-portlet Reported 733, had 63 — moduleResolution: node10 broke 256 imports. Also corrects the portlets guide that recommended it
#35961 portlets-dot-analytics Spectator's typed props vs Angular's input aliases; two more type-only barrels; @types/d3-* added
#35959 content-drive-ui A drop with no active node emitted a payload its own type forbids
#35956 new-block-editor 38 of 60 were TS4111 on TipTap attrs; EditorView sourced from @tiptap/pm/view; @types/turndown added
#35953 ui 549 → 0. The rollout's bottleneck: ~109 errors leaked into each of its 26 dependents
#35948 data-access Flags were present but inert (no build target) — fixes the 36 lib + 47 spec errors hiding behind them
#35947 sdk-angular Already compliant — removes dead next/ tsconfig refs that made tsconfig.spec.json unverifiable (TS6053)
#35946 sdk-analytics Enable the 5 missing flags + fix 18 TS4111 in source and 29 in specs (14 pre-existing)
#35945 sdk-react Enable the 5 missing flags + fix 14 TS4111 index-signature accesses
#35944 utils-testing Strict was declared but inert — a stale types: ["jasmine"] aborted all type checking
#35940 utils Enable strict + fix the 32 (+17 spec) resulting type errors
#35939 dotcms-js Enable strict + fix the 38 resulting type errors
#35938 sdk-create-app Enable strict + fix the 2 resulting type errors
#35935 sdk-types No code needed — it was already strict. Documents the rollout pattern instead.

All three add the standard six flags to the project's own tsconfig.json, following the pattern established in #36879 (dotcms-models). tsconfig.base.json stays at "strict": false — the rollout never flips it globally.


dotcms-js (#35939)

The largest of the three: 38 errors across 11 files, in a layer-1 core library with 20 dependent projects, including the dotcms-ui admin app. Six of those dependents are already strict, so this library's loose types were leaking uncertainty into projects that had opted into rigour.

Most fixes correct types that were simply wrong, rather than silencing the compiler:

Site Was Reality
Auth.loginAsUser User The code has always passed null when nobody is impersonating, and every consumer already guards with auth.loginAsUser || auth.user. Now User | null.
StringUtils.getLine string Its own JSDoc says "null if it does not exists". Now string | null.
HttpRequestUtils.getQueryStringParam string Same — JSDoc already documented the null case.
RoutingService.getPortletURL string Returns Map.get(). Now string | undefined.
SiteService.switchSiteById Observable<Site> Emits of(null) when no site is found. Now Observable<Site | null>; its one consumer already handled null.
ResponseView.bodyJsonObject DotCMSResponse<T> Assigned from HttpResponse.body, which is nullable. The surrounding try/catch could never throw and has been removed.

LoginService.urls moved from Record<string, string> to inference-typed, which resolves all 8 TS4111 errors at once and gives each endpoint a named property.

Two definite-assignment assertions were used, each with a TODO: LoginService._auth and SiteService.selectedSite are assigned during init but not in the constructor. Modelling them as | undefined is the truthful type, but their public getters (auth, currentSite) are consumed by already-strict projects, so widening them is a public-API change that belongs in its own issue.

No new any, @ts-ignore, or @ts-expect-error anywhere in this PR.

⚠️ dotcms-js has no build target and is tag-excluded from lint and test, so nothing in CI verifies these flags. They document intent; they do not enforce it. This was an explicit scoping decision — no typecheck target or CI gate was added. The six already-strict consumers provide partial, incidental coverage only. Full reasoning in specs/35939-dotcms-js-strict-mode/spec.md, which is included in this PR.

Blast-radius verification

data-access (a strict consumer) went from 106 type errors to 68, with zero new errors introduced — the honest types upstream remove noise downstream. dotcms-ui typechecks clean apart from a pre-existing missing dotcms-webcomponents/loader dist.


utils (#35940)

32 errors across only 3 files, plus 17 more that appeared in the spec files once the flags propagated through tsconfig.spec.json (baseline there was 0). Both are fixed here — leaving the spec errors would have shipped a regression.

The bulk was one constant. EMPTY_FIELD assigned null to 18 members that DotCMSContentTypeField declares non-nullable:

  • Replaced with zero values of the declared types. Verified safe: nothing compares those members to null strictly — consumers use falsy checks such as isNewField's !field.id — so '', 0 and false behave identically at runtime.
  • clazz has no zero value (DotCMSClazz is a union of concrete Java class names), so EMPTY_FIELD and EMPTY_SYSTEM_FIELD are now Omit<DotCMSContentTypeField, 'clazz'>. They are partial templates, not valid fields, and the type now says so. The derived COLUMN_FIELD / ROW_FIELD / TAB_FIELD already supply their own clazz, so they remain complete.

Other fixes:

Site Change
getFieldsWithoutLayout Truthy .filter() did not narrow the optional row.columns. A type predicate clears the TS2532 and both TS2769 without a cast.
ellipsizeText Accepted null/undefined at runtime — its own guard and its tests say so — but declared string/number. Widened to match, with an explicit limit == null check so later comparisons narrow.
fallbackErrorMessages Typed { [key: number]: string }, mirroring the identical declaration already in libs/data-access/.../dot-upload.service.ts.
dot-utils.ts Bracket access for the six DotCMSContentlet index-signature reads in getImageAssetUrl.
dot-asset.service.ts Explicit types for promises and the two fetchAsset params.

The nine as unknown as casts added are all in spec files, on inputs the tests deliberately pass as invalid, matching the idiom those files already used.

⚠️ Same enforcement gap as dotcms-js: utils has no build target and is tag-excluded from lint and test, so nothing in CI verifies these flags. Accepted trade-off, consistent with #35939.

Blast-radius verification

data-access (strict consumer) went from 68 type errors to 36, zero new. utils-testing (strict) unchanged at its 1 pre-existing error — the Omit did not break its EMPTY_SYSTEM_FIELD spread.


sdk-create-app (#35938)

Two errors, both from flags beyond plain strict:

  • src/index.ts:393process.env.DEBUG needs bracket access under noPropertyAccessFromIndexSignature (TS4111). It is the only process.env.* dot access in the project.
  • src/utils/index.ts:41fetchWithRetry tripped noImplicitReturns (TS7030). The loop returns on success and throws on the last attempt, but with retries < 1 the loop never runs and the function fell through returning undefined. Its only caller (isDotcmsRunning, src/index.ts:506) already guarded with if (res && …), so nothing broke in practice — but the signature was lying. Throwing after the loop closes the gap and narrows the return type to Promise<AxiosResponse>.

No build or CI wiring was needed here. The @nx/esbuild:esbuild executor type-checks before bundling (skipTypeCheck defaults to false and is not overridden), and CI already builds this project via nx run-many -t build (build-test in core-web/pom.xml). The same build runs in the SDK release pipeline (cicd_release-sdk.ymlnx run-many --projects='sdk-*'), so the flags are enforced on every release.


sdk-types (#35935)

libs/sdk/types/tsconfig.json has carried strict: true plus the four extra safety flags since the library was created (#31967), and tsc --noEmit passes with zero errors. It is also already enforced: tsconfig.lib.json sets "declaration": true, so @rollup/plugin-typescript sits in the Rollup chain and fails the build on a strict violation.

So no code change was required. What was missing was documentation, added here to core-web/CLAUDE.md:

  • A ## TypeScript Strict Mode section covering the per-project flags, what actually enforces them, and the Vite exception (esbuild skips type checking, which is why the Nx Vite plugin infers a separate typecheck target).
  • Fixes a line that forbade "strict": true in project tsconfigs. It sat under the Jest config guidance but read as a blanket ban, contradicted docs/frontend/TYPESCRIPT_STANDARDS.md, and blocked the epic outright. The restriction now points at tsconfig.spec.json, which is what it meant.


utils-testing (#35944)

The six strict flags were already in tsconfig.json — but completely inert. tsconfig.lib.json declared "types": ["jasmine"], that package is not installed, so tsc emitted TS2688: Cannot find type definition file for 'jasmine' and stopped before semantic checking. The project reported exactly one error regardless of what the code did.

The reference was stale: nothing uses jasmine, two files use jest.*, and @types/jest is installed. Switching to "types": ["jest"] removed the abort and 27 spurious Cannot find name 'jest' errors, leaving 5 real ones:

Site Fix
clean-up-dialog.ts Untyped fixture param → typed structurally as { nativeElement: unknown }, since only that property is touched (no need to pull in Angular's ComponentFixture)
dot-page-state.service.mock.ts _lock: boolean = nullboolean | null
dot-page-tools.mock.ts ×3 Mock entries carried a tags array that DotPageTool does not declare. Verified nothing in the repo reads .tags off a page tool, so the dead field was removed rather than added to the model in dotcms-models

tsc -p libs/utils-testing/tsconfig.lib.json --noEmit now exits 0 with no CLI overrides — the check is real rather than short-circuited.

Verified across consumers of the touched mocks (cleanUpDialog in 7 files, page-tools mock in 3): data-access 751 tests passed, edit-ema-ui 338 passed.


dotcms-webcomponents (#35943) — groundwork only, not closed

Strict is not enabled here. ~250 errors remain across 38 files, and unlike the other projects this one has no skip:build, so Stencil type-checks it on every PR — flipping the flag early turns CI red. What landed is the part that is correct on its own.

The decorator split, which is the load-bearing decision. Stencil declares runtime-injected members without initializers, colliding with strictPropertyInitialization (139 of the original 375 errors). The fix cannot be uniform:

Decorator Count Fix Why
@Event 57 ! Internal; the runtime creates the EventEmitter
@Element 27 ! Internal; the host element
@State 25 ! Internal component state
@Prop 30 ? Public API

Using ! on @Prop made Stencil emit 28 props as required in components.d.ts — breaking for any TS/JSX consumer. With ? the generated API moves required → optional, which is backward compatible. Measured in the generated file, not assumed.

Two traps recorded on the issue

Stencil under-reports. Its build shows ~10 files / ~39 errors per run, not the total. Measured at the same commit: Stencil 39 errors / 10 files vs tsc 250 / 38. Size this work with tsc, not with build output.

--skip-nx-cache does not clear Stencil's cache. Builds can report green against stale .stencil output. This bit me: 0117273504 annotated a prop, passed a "clean" build, and was actually broken — reverted in f22afce383 after verifying twice with .stencil and the Nx cache cleared.

That prop (dot-binary-text-field's value) is genuinely contradictory: handleFilePaste assigns a File, other paths assign strings, and the template feeds it to an <input value> that accepts neither. No annotation describes the current code — the render path has to be fixed first. Left untyped with a TODO(#35943) so it is not re-annotated in isolation.


sdk-react (#35945)

strict: true was already present; the five companion flags were not. Adding them surfaced 14 errors, all TS4111 — dot access on a type carrying an index signature — resolved with bracket notation. Two origins, same fix:

  • 13 from node.attrs, declared Record<string, any> in @dotcms/types. That type is deliberately left alone: block editor attributes really are dynamic, and it lives in a layer-0 project whose consumers would all be affected.
  • 1 from CSS Modules (styles.row in Row.tsx), whose generated type is also a Record<string, string>.

No behaviour change — bracket access compiles to the same property lookup.

The flags here are genuinely enforced, and that was proved rather than assumed. Reverting one access to dot notation fails the build with @rollup/plugin-typescript TS4111, confirming TypeScript sits in the Rollup chain. The project carries no skip: tags, so CI builds, lints and tests it on every PR, and the same build runs in the SDK release pipeline.

One error remains under plain tsc and is expected: Cannot find module 'virtual:sdk-version' in sdk-client — a Vite virtual module that raw tsc cannot resolve but the build can. It predates this change and is unrelated to strict mode. Worth knowing when measuring, or the count reads 15 instead of 14.


sdk-analytics (#35946)

Same starting shape as sdk-react: strict: true already present, the five companion flags absent. But this one is not enforced, and that was established by test rather than inference.

18 errors in production source, all TS4111 from noPropertyAccessFromIndexSignature — dot access on HTMLElement.dataset (DOMStringMap) and on a Record<string, unknown> of payload properties. Bracket notation throughout, reads and writes alike. Spread across dot-analytics.utils.ts (10), dot-analytics.click-tracker.ts (4), dot-analytics.impression-tracker.ts (3), dot-analytics.click.utils.ts (1).

29 errors in specs — 15 more of the same mechanical dataset fix, plus 14 that were pre-existing drift rather than strict-mode fallout. Confirmed pre-existing: they persist identically under --strict false. Two independent gaps had let them accumulate unseen — the inferred typecheck target runs only tsconfig.lib.json, and jest.config.ts transforms via babel-jest, which strips types without checking them.

Root cause Count
ANALYTICS_CONTENTLET_CLASS no longer exported — renamed to CONTENTLET_CLASS 2
Pageview fixture put device inside data and omitted required locale_id 4
Untyped jest.fn() inferring never for mockResolvedValue / mockRejectedValue 3
Location mock missing host 1
jest.spyOn(...).mockImplementation() called with no argument 2
mockInitialize inferred as zero-arg 1
result.custom — not on EnrichedTrackPayload 1
TS2589 excessively deep instantiation 1

The pageview fixture was the instructive one: with device misplaced and locale_id missing, the pageview member of the DotCMSEvent union stopped matching, so TypeScript fell through to the impression member and reported a misleading "doc_encoding does not exist on DotCMSContentImpressionPageData". One coherent fix cleared four errors. Fixtures were corrected rather than production types widened; no source bug hid behind any of them.

⚠️ Negative test says the build is not a gate. Unlike sdk-react, this project builds through Vite. It does run dts({ tsconfigPath: 'tsconfig.lib.json' }) with vite-plugin-dts@4.5.4, which invokes the TS compiler to emit declarations — so the build plausibly could have enforced the flags. It does not: a deliberate const __strictProbe: number = "definitely not a number"; in a lib source file did not fail nx run sdk-analytics:build --skip-nx-cache. vite-plugin-dts emits declarations without failing on diagnostics, and CI never invokes typecheck. Probe reverted immediately.

So sdk-analytics joins dotcms-js and utils as strict but unenforced. Wiring nx affected -t typecheck into core-web/pom.xml was deliberately left out — it is monorepo-wide and belongs to the epic, not to project 13 of 44. Both gaps are raised on #35932.

This also corrects the pattern proposed in #35942 — that every libs/sdk/* project was already strict and already enforced. That holds for the Rollup-built SDK libs, which type-check through @rollup/plugin-typescript (as sdk-react proved). It does not hold for Vite-built ones: sdk-analytics inherited strict from the shared tsconfig lineage but neither the other five flags nor a type-checking build.

0 internal dependents — the only references to @dotcms/analytics outside the lib are doc comments in libs/sdk/uve/src/internal/constants.ts. No blast radius.


sdk-angular (#35947)

No flags were added — all six were already there, plus Angular's strictTemplates, strictInjectionParameters and strictInputAccessModifiers. Re-adding them would have been a cosmetic diff. The real defect was dead config.

Both tsconfig.lib.json and tsconfig.spec.json referenced a next/ directory that existed and was removed — the references landed on 2025-03-21 (09e879b2ac) and outlived the directory. One of them was fatal:

error TS6053: File '.../libs/sdk/angular/next/test-setup.ts' not found.
  The file is in the program because:
    Part of 'files' list in tsconfig.json

tsc aborts on that before semantic checking, so tsconfig.spec.json had never completed a single semantic pass and any error count taken from it was meaningless. The asymmetry is the lesson: a non-matching include glob is harmless, a missing files entry is fatal — which is why tsconfig.lib.json, whose next/ references were only in include/exclude, kept working.

Removed the dangling references from both. Both configs now report 0 own errors; the one remaining error in each is the pre-existing, unrelated virtual:sdk-version from sdk-client documented in the sdk-react section above.

The spec config coming out clean was predicted, not lucky: jest-preset-angular@17ts-jest@29.4.6 with diagnostics enabled and transpile-only unset already type-checked all 21 spec files against these exact compilerOptions — just per-file, never as a whole program. That is the opposite of sdk-analytics below, where babel-jest stripped types and hid 14 errors. Same rollout, two projects, and the test transformer decided whether anything was checked at all.

Negative test confirms the build is a real gate. A deliberate const __strictProbe: number = "definitely not a number"; in lib/store/dotcms.store.ts fails nx run sdk-angular:build with TS2322 and exit code 1 — @nx/angular:package runs ngtsc. Probe reverted, file byte-identical to git. No typecheck target was added; per CLAUDE.md that is redundant when the build already type-checks.

Production source is clean without escape hatches: 0 @ts-ignore / @ts-expect-error, 0 non-null assertions, and 2 anys that are the same exported declaration (DynamicComponentEntity = Promise<Type<any>>, lib/models/index.ts:12). Type<any> is idiomatic Angular for dynamically-loaded components and the type is public API, so narrowing it is a separate change, not strict-mode work. 0 internal dependents.

CLAUDE.md now documents the TS6053 masking variant next to the existing TS2688 one. Two of the fourteen projects triaged so far were masked this way — #35944 via TS2688, #35947 via TS6053 — so error counts from the remaining projects should not be trusted until their tsconfigs are checked for this.


data-access (#35948)

First non-isolated project in the rollout: 27 direct dependents, 6 of them already strict.

All six flags had been in libs/data-access/tsconfig.json for some time, and they were completely inert. The project has no build target, so its own tsconfig is never read by anything, and its 27 dependents compile these sources under their own non-strict configs. So 36 errors sat in a layer-3 shared services hub with CI fully green — matching the 106 → 68 → 36 drift measured incidentally in the dotcms-js and utils sections above.

Config Before After
tsconfig.lib.json 36 0
tsconfig.spec.json 84 (47 own + 37 lib pulled in) 0

Production source (36)

  • paginator.service.ts (14) — 8 uninitialised fields given zero values; four header reads take ?? '' (identical NaN outcome); private setLinks(linksString: string) widened to string | null since its body already did linksString?.split(',') || []; the file-local interface Links gained an index signature because the Link-header parser stores whatever rel the server sends.

    _sortOrder was deliberately left optional rather than defaulted. getParams() gates the direction query param on truthiness, and OrderDirection.ASC === 1 is truthy where undefined was not — defaulting it would have made every paginated request in the admin UI start sending a param it previously omitted.

  • dot-page-state.service.ts (12) — six declarations widened because they were simply wrong; the service really does emit null. The interesting one is handleSetPageStateFailed, declared Observable<DotHttpErrorHandled> but ending in map(() => undefined). Because it genuinely emits undefined, the caller's = [null, null] destructuring default is reachable and load-bearing, not dead code. Declaring the honest type made the whole switchMap typable; it now destructures explicitly instead of fighting an annotation. if (page) became if (page && user) — which forkJoin already guaranteed.
  • dot-router (5), dot-localstorage (3), dot-content-types-info (2) — nullable getters (previousUrl, storedRedirectUrl), localStorage reads, and a string index narrowed to keyof.

Specs (47)

25 came from three lines. The fake Router declared navigate = jest.fn(() => ...), which infers zero parameters, so every toHaveBeenCalledWith(...) was a TS2554.

Gotcha for the remaining projects: this file's jest is @types/jest, which uses jest.fn<TReturn, TArgs>two type parameters. @jest/globals (as in sdk-analytics) uses jest.fn<Fn>. Wrong arity gives TS2743.

Two of the rest were real bugs hiding behind disabled suites:

  • dot-global-message.service.spec.ts imported DotMessageService from dot-alert-confirm.service, which does not export it. The suite is xdescribed, so it never ran.
  • dot-ai.service.ts — a production file — did export { DotAiProviderConfig } on a type, invalid under isolatedModules. Only the spec config sets that flag, so only it surfaced the error.

Also: dot-page-layout.service.spec.ts was calling save(id, mockDotLayout()), but save takes a DotTemplateDesigner and posts it verbatim — the spec was testing a payload shape production never sends (edit-ema-layout.component.ts:111 sends the real one). And the dot-content-drive fixture used an offset field removed from DotContentDriveSearchRequest, copied from the model's own stale JSDoc example, which is fixed here too. dot-personas now reuses the existing mockDotPersona from @dotcms/utils-testing instead of hand-rolling 21 fields.

No new any, no @ts-ignore / @ts-expect-error anywhere in the diff.

Blast radius — measured, not assumed

Every strict dependent was counted before and after. Zero new errors, 218 removed, and three went fully clean because they were carrying nothing but this library's leakage:

Strict dependent Before After
global-store 36 0
portlets-dot-analytics-data-access 36 0
portlets-dot-experiments-data-access 36 0
image-editor 148 111
portlets-dot-analytics 151 115
portlets-dot-locales-portlet 147 111
utils-testing 0 0

This is the "high leverage" the issue predicted, quantified.

⚠️ Still unenforced, consistent with dotcms-js and utils above — no build target, so nothing catches a regression of these 83 fixes. That is now four projects in this state; raised on #35932 rather than solved per-project.

A repo-wide finding: test never type-checks specs

data-access uses jest-preset-angularts-jest@29.4.6 against tsconfig.spec.json, which looks like it type-checks. It does not, because that tsconfig sets isolatedModules: true:

  • ts-jest/.../config/config-set.js:229 reads TypeScript's isolatedModules into ts-jest's own flag.
  • ts-jest/.../compiler/ts-compiler.js:74 builds the language-service host only if (!isolatedModules).
  • _doTypeChecking() needs that host for getSemanticDiagnostics.

data-access is the proof: 84 tsc errors alongside 754 passing tests. Since core-web/CLAUDE.md mandates isolatedModules: true in every tsconfig.spec.json, no project's test target type-checks its specs anywhere in this monorepo — so "tests pass" has never been evidence of spec type-cleanliness. This corrects the justification given in the sdk-angular section above (that verdict was separately confirmed with tsc -p, so it stands). Removing the flag would enable checking monorepo-wide and is left to the epic.


Batch two: twelve more projects (#35949 #35950 #35951 #35952 #35954 #35960 #35962 #35963 #35965 #35968 #35969 #35970)

Bottom-up, and the ordering mattered more than the raw counts suggested.

libs/ui first, because it was the bottleneck (#35953 — still open)

ui had none of the six flags and 549 own errors, and ~109 of them leaked into each of its 26 dependents. Clearing its library program collapsed the portlets that follow:

Project Before ui After
portlets-dot-usage 219 0
portlets-dot-tags-portlet 238 22
portlets-dot-locales-portlet 243 27
portlets-dot-es-search-portlet 246 30
portlets-dot-categories-portlet 249 33
portlets-dot-analytics 266 50
content-drive-ui 275 59

ui's tsconfig.lib.json is at 0 (from 122) and its tsconfig.spec.json at 90 (from 427), so #35953 stays open. Notable findings there:

  • dot-icon's size became size?: number rather than = 0, because the template binds [style.font-size.px]="size" and 0 would have rendered invisible icons where undefined inherits.
  • dot-sidebar, dot-dropdown, dot-site-selector, dot-container-options and dot-trim-input all inject their host with { optional: true } and then used it unguarded. They now guard.
  • Types that were wrong rather than merely loose: formEl was declared HTMLFormElement while the template says #formEl="ngForm"; getVariableIndexChanged declared number while its own JSDoc documented number | null.
  • A real defect: dot-add-to-bundle invoked getDefaultBundle twice for the same value.
  • tsconfig.lib.json excluded a non-existent src/test.ts but not test-setup.ts, **/*.test.ts or __mocks__/, so test files were being compiled into the library program — 15 errors by itself.
  • Specs went 427 → 90 mostly by asserting at the point of declaration: 53 usages of one select local came from a single line.

Already compliant, verified rather than assumed

#35950 portlets-dot-locales-data-access and #35952 portlets-dot-experiments-data-access needed no change: all six flags present, both configs at 0, and neither masked by a TS6053/TS2688 config error. Both reported 36 errors before #35948 — purely data-access leaking.

#35949 sdk-experiments was a flags-only change. The cost was measured on the CLI before committing (0 with all six), and enforcement was proved by negative test: it builds with Rollup, so a deliberate type error fails the build with @rollup/plugin-typescript TS2322.

Barrel files that only their consumers could see

#35951 global-store re-exported WebSocketStatus — a type — with export {}, which is TS1205 under isolatedModules. Its own configs never reported it, because no spec there imports ./index, so the file was never in its own program. It surfaced only from consumers, showing up as the single spec error attributed to #35954 portlets-dot-analytics-data-access. One export type closed both issues.

Third and fourth instances of this shape followed in dot-analytics's two barrels. A barrel can carry an isolatedModules error that only its consumers ever see — worth a repo-wide sweep, raised on #35932.

An ambient declaration in the wrong place

The htmldiff-js declaration added for ui lived under libs/ui/src, so it was only in ui's own program and every consumer still reported TS7016. It is now registered through tsconfig.base.json from a root-level types/ folder — inside libs/ui made @nx/enforce-module-boundaries demand a relative import. Same shape as the known virtual:sdk-version leak from libs/sdk/client.

Signal stores are the dominant spec pattern

#35968 dot-tags (18 of 22), #35963 dot-es-search (all 28) and #35962 dot-categories (15 of 31) were all the same theme: a Signal<T> does not structurally overlap a jest.Mock, so casts must route through unknown, and assignments of jest.fn() onto signal-typed members must state the type they stand in for.

dot-categories also had fixtures incomplete in two directions — DotCMSAPIResponse needs four fields beside entity (now a shared API_ENVELOPE rather than repeated nine times) and DotCategoryDeleteResult needs deletedCount — plus four calls passing Event where openRowMenu takes a MouseEvent.

Wrong annotations, not loose ones

#35965 dot-locales: both dialog refs were annotated DynamicDialogRef, but DialogService.open() is typed as possibly null in this PrimeNG version. Its store spec mocked Observable<void> methods with of(null).

#35960 edit-content-bridge: the dialog ref is now captured in a local so its non-nullness is evident rather than asserted. In its spec, reconcileOnFormEvent is assigned inside a nested callback, which control-flow analysis cannot see, so TypeScript narrowed it back to null and called it not callable.

#35969 portlets-dot-usage: one fixture missing UsageSummary.lastUpdated.

The first app, and what it revealed about templates

#35970 dotcdn had 19 errors, and 8 shared one cause: dispatchLoading's switch had no default, so under noImplicitReturns the updater's return type included undefined, it stopped resolving as a one-argument updater, and all six call sites reported TS2554: Expected 0 arguments. default: return state fixed all eight.

More importantly, its build failed on libs/ui's templates, not on dotcdn:

libs/ui/.../dot-action-menu-button.component.html:1:14 - error TS2532: Object is possibly 'undefined'

libs/ui has no build target, so its templates had never been null-checked — they are only verified when a consuming app compiles them.

tsc -p does not check templates. Every per-project count in this rollout taken that way misses the class entirely. Probed across the three remaining apps by temporarily enabling the flags and building: 2, 8 and 23 template errors against 350–2500 .ts errors. Real, but ~1% of the volume, so it does not change the plan. Reported on #35932.

dotcdn and edit-content-bridge both have build targets, so their flags are genuinely enforced. The rest of this batch is not — no build target, and :test does not type-check.

Dependencies

Added @types/d3-scale, @types/d3-selection and @types/d3-shape. All three d3 packages are direct dependencies with no bundled types, so those imports were implicitly any. Maintained DefinitelyTyped packages, so installing beats hand-declaring the modules.


Batch three: libs/ui finished, plus four more (#35953 #35956 #35959 #35961 #35966)

libs/ui (#35953) — 549 → 0, and it unblocked most of what followed

Both configs are now clean: tsconfig.lib.json 122 → 0, tsconfig.spec.json 427 → 0. ~109 of its errors leaked into each of its 26 dependents, so clearing it collapsed seven projects from 219–275 down to 0–59. portlets-dot-tags-portlet had 1 error of its own, not 238.

Highlights beyond the mechanical work:

  • Types narrower than their own implementation. DotLocaleTagPipe guards with if (!languageId || !languagesMap), DotRelativeDatePipe with const time = date || Date.now(), and onAssignChange/onCommentChange with ?? '' — all three declared non-nullable parameters, making those guards unreachable and the specs that assert the null behaviour uncompilable.
  • A type contradicting the API. DotLanguageVariableEntry declared every language's value as always present; the API omits languages without a variable and the component already reads them with ?.value.
  • A spec referencing a removed type. dot-browsing.service.spec imported SiteEntity, which no longer exists — dot-site.model.ts says to use DotSite, and createFakeSite already returns it.
  • Real defects: dot-add-to-bundle invoked getDefaultBundle twice for the same value; five directives injected their host with { optional: true } and used it unguarded; formEl was declared HTMLFormElement while the template says #formEl="ngForm".
  • Test files were in the library program. tsconfig.lib.json excluded a non-existent src/test.ts but not test-setup.ts, **/*.test.ts or __mocks__/ — 15 errors by itself.

Two techniques worth reusing: fix at the point of declaration (53 usages of one select local came from a single line; 95 declaration-level assertions cleared ~300 spec errors), and read each TS2564 site rather than applying the policy blindlydot-icon's size became size?: number instead of = 0, because the template binds [style.font-size.px]="size" and 0 renders invisible icons where undefined inherits.

dot-plugins (#35966) — reported 733, had 63

tsconfig.spec.json used module: "commonjs" + moduleResolution: "node10", and tsconfig.json was missing moduleResolution: "bundler". node10 cannot resolve the @dotcms/* subpath exports, so 256 imports failed and everything downstream collapsed to unknown (227 TS2571, 115 TS18046, 85 TS2339). Aligning both with dot-tags took it from 733 to 1.

Swept the other remaining projects — dot-plugins was the only one affected. So the large counts for block-editor, dot-rules, edit-content and edit-ema-portlet are real work.

libs/portlets/CLAUDE.md was telling people to configure it that way. Its anti-patterns table said to omit strict: true ("causes issues with Angular compiler") and to use "module": "commonjs" in tsconfig.spec.json — while dot-tags, which the same guide calls the canonical reference, carries both strict: true and module: "preserve" and compiles clean. Corrected, so the next portlet does not repeat it.

dot-analytics (#35961) and content-drive-ui (#35959)

Spectator's typed props disagrees with Angular for aliased signal inputs. Components declare $tableState = input.required({ alias: 'tableState' }). Spectator's InferInputSignals keys off the field name; Angular's setInput requires the alias. The specs passed the alias under an as unknown cast that made the props bag unknown, so removing it surfaced TS2561 with a "did you mean $tableState" hint — and following that hint broke 12 tests. The alias wins; the cast is narrowed to the props type derived from the factory, with a comment naming the conflict.

A drop with no destination. dot-tree-folder's onDrop read the nullable $activeDropNode() and emitted it as targetFolder, which both payload types declare non-null. A drop outside any folder emitted an invalid event; it now returns early.

Also: two more barrels re-exporting types with export {} (third and fourth instances after #35948 and #35951), and @types/d3-scale / @types/d3-selection / @types/d3-shape added — direct dependencies with no bundled types.

new-block-editor (#35956)

38 of 60 were TS4111 on TipTap node attrs, converted from the exact positions tsc reports. EditorView is annotated from @tiptap/pm/view, not top-level prosemirror-view — the file's own comment explains that TipTap 3.x nests its own copy and mixing the two yields TS2322; the comment now names the correct source instead of saying the import is avoided entirely. Three plugin state fields had init: () => null, pinning the state type to null. @types/turndown added.

On my own mistakes in this batch

Three self-inflicted breakages, all from over-broad regexes, all caught by running the suites: contentlet?.assetcontentlet?['asset'] (invalid syntax, which then masked every other error), a (view) replacement that hit call sites as well as declarations, and — the one that mattered — "completing" a fixture that deliberately omitted Action.name, which is exactly the case its test asserts on. tsc was happy with that last one; only the test caught it.

Dependencies added in this batch

@types/d3-scale, @types/d3-selection, @types/d3-shape, @types/turndown. All four are direct dependencies whose imports were implicitly any; these are maintained DefinitelyTyped packages, so installing them beats hand-declaring the modules.

Batch four: block-editor (#35955)

The first of the four large projects. Estimated at ~743 own errors; the real figure was 442 — clearing libs/ui had already removed ~300 of them without this project being touched. No inherited errors at all, so all 442 were local.

block-editor has no build target, so tsc -p on tsconfig.lib.json and tsconfig.spec.json is the acceptance test. There is no build to lean on.

Four wrong declarations, not 442 unrelated fixes

DotMenuItem extends Omit<MenuItem, 'icon'> erased every declared member of MenuItem. PrimeNG's MenuItem carries a [key: string]: any index signature, so keyof MenuItem is string | number, and Exclude<string | number, 'icon'> removes nothing — Omit collapsed the type to its index signatures alone. id, label, command, disabled: all silently any. I probed it rather than assuming:

type Omitted = Omit<MenuItem, 'icon'>;
declare const viaOmit: Omitted['label'];    // any                → tsc reports nothing
declare const viaDirect: MenuItem['label']; // string | undefined → tsc reports it

MenuItem already declares icon?: string, so the omission bought nothing. Extending it directly cleared 12 TS4111 and gave every consumer real types back.

ImageNode referenced itself. addCommands used ImageNode.name inside ImageNode's own initializer, so TypeScript typed the whole extension any (TS7022). this.name is the same value and breaks the cycle — which then exposed a real Map inference problem in DotBlockEditorComponent._customNodes that the any had been hiding.

loadCustomBlocks had the wrong element type. Declared PromiseSettledResult<AnyExtension>[], but import(url) yields a module namespace — the element type is Record<string, AnyExtension>.

editor.storage.dotConfig was optional although getEditorExtensions() registers DotConfigExtension unconditionally, first in the list. Existing readers were split between ! and ?.. Declared required, and dot-config.types.ts — an unreferenced duplicate of the same module augmentation — deleted, since two copies that must stay in sync is exactly how this drifts.

Deleted rather than initialised

Two fields were never assigned and never read: FloatingActionsView.element and AIImagePromptView.tippyOptions. Giving them an initializer to satisfy TS2564 would have preserved dead code.

Seven errors were already present without any strict flags, two of them broken references:

  • asset-form.component.spec.ts tested ImageTabviewFormComponent, a component that does not exist on main either. Deleted.
  • dot-upload-asset.component.spec.ts imported DotUploadFileService from block-editor's shared barrel instead of @dotcms/data-access, so it provided a different token than the component injects.

Flagged, not changed

FloatingActionsView.update calls this.render().onExit(null), but ActionsMenu's onExit destructures editor from its argument. Preserved exactly, behind a cast and a FIXME(#35955) — changing runtime behaviour does not belong in a type-only pass.

placeholder.plugin keeps tr.getMeta(this). It looks wrong, but ProseMirror binds a state field's apply() to the Plugin instance, which is what makes it match the tr.setMeta(PlaceholderPlugin, …) calls. Annotated the this parameter to record that.

The test suite was already red — and stayed exactly as red

block-editor:test fails 16 suites / 37 tests on main. libs/block-editor is byte-identical between main and this branch, so this is Angular 22 migration debt, not something the epic introduced. Now tracked in #37091.

Per the agreed scope, this PR is types-only and left the suite alone. I verified that by name, not by count — a different set of 37 failures would have the same total:

=== only BEFORE (fixed by my changes) ===
=== only AFTER (new failures = regressions) ===

Same 38 entries, nothing added, nothing accidentally fixed. The cost is real and worth stating plainly: for this project I had no runtime safety net, which is the guard that caught my worst mistake earlier in this PR.

Dependents

Measured by checking out libs/block-editor at 82dbf4c9ad and re-running each dependent's tsc -p, so the delta isolates this change from the rest of the branch.

Dependent Before After
dotcms-block-editor 1 0
dotcms-ui 1 0
edit-ema-ui 2 0
edit-content 29 27
portlets-edit-ema-portlet 190 188

Zero new errors; three cleared outright.

dotcms-block-editor (#35973) — and the template gate block-editor never had

Three of this app's tsconfigs — spec, editor and the shared tsconfig.json they extend — declared "types": ["jasmine", "node"]. @types/jasmine is not installed, nor are karma-jasmine or jasmine-core, so each aborted with TS2688 before semantic checking. None had ever type-checked anything. The @angular/build:karma test target cannot run for the same reason, and the app has no .spec.ts files at all.

With that removed and the flags added, tsc -p was clean on all three — and the build failed:

TS2339: Property 'contentlet' does not exist on type 'never'
  libs/block-editor/.../suggestions-list-item.component.html:1:35

SuggestionsListItemComponent.data was declared = null, which under strict infers the type null, so data?.contentlet narrowed to never. Typed properly now.

The wider point: libs/block-editor has no build target, so its templates had never been type-checked by anything. tsc -p does not check templates, and it was the only gate #35955 had. #35973 is what puts them under a real one — which is why closing a small app mattered more than its error count suggested.

A spec file that disabled type checking across three libraries

While measuring #35974, libs/edit-content/.../dot-edit-content-field.component.spec.ts turned out to declare:

/* We need this declare to dont have import errors from CommandType of Tiptap */
declare module '@tiptap/core' {
    interface Commands {
        [key: string]: { [key: string]: (...args) => any };
    }
}

Module augmentations are global to the program. This one gave TipTap's Commands a string index signature for every file compiled alongside it — which is how editor.chain().focus(), a real declared command, became "Property 'focus' comes from an index signature".

edit-content's program pulls in 249 files from block-editor plus all of new-block-editor, so under strict flags it produced 256 errors in those two libraries' sources (149 + 107). Both compile clean under their own configs, so nothing could see it until a consumer went strict.

The comment's premise no longer holds: measured with the augmentation present and absent on the current non-strict config, edit-content reports 27 lib / 48 spec errors either way. It suppressed nothing and cost 256 unchecked sites. Removed.

A sixth way to get a green signal that checked nothing

TS5101 (baseUrl) and TS5107 (moduleResolution: node10) are deprecation errors under TypeScript 6 — and, like TS2688 and TS6053, they abort before semantic checking. libs/dotcms-webcomponents reports 2 errors without --ignoreDeprecations 6.0 and 279 with it.

It cannot set the option in its tsconfig: Stencil bundles TypeScript 5.8.3, which only accepts "5.0", while the workspace runs 6.0.3, which requires "6.0". No single value satisfies both, so the CLI flag is mandatory — the comment there now says so, and core-web/CLAUDE.md records the general rule: any TS5xxx/TS6xxx/TS2688 error is a configuration error, and the count after it means nothing.

Batch five: edit-ema-ui (#35971) and template-builder (#35958)

Two projects taken to 0, both with tests green throughout and lint back at its clean baseline.

A fixtures file compiled as production code

template-builder reported 27 inherited errors, all Cannot find name 'jest' in libs/utils-testing. utils-testing was not the problem: tsconfig.lib.json excludes *.spec.ts but not src/**/utils/mocks.ts, so a fixtures file importing @dotcms/utils-testing was in the lib program under types: []. Only specs import it and it is not in the public barrel, so it is now excluded from the lib build. edit-content reports the same 27 from the same cause.

sidebar is null when empty, and the model never said so

DotLayout.sidebar and DotTemplateLayoutProperties.sidebar were both declared non-nullable while TemplateBuilderComponent deliberately clears them:

sidebar: layoutProperties?.sidebar?.location?.length // Make it null if it's empty so it doesn't get saved
    ? layoutProperties.sidebar
    : null,

Two specs assert it. Widening the shared model surfaced three unguarded reads in the store — which is the point — and had zero impact on the eight strict projects that consume DotLayout.

Nearly changing a wire payload

publishContentletAndWaitForIndex takes { [key: string]: string | number }, and dot-favorite-page.store sends inode: formData.inode || null. My first fix was ?? ''. The spec caught it, because it asserts the payload contains inode: null — the endpoint distinguishes null from an empty string. The signature was wrong, not the call; widened it in data-access.

Same shape in DotContentCompareStore, which piped httpErrorManagerService.handle(err) out of catchError into a switchMap typed for contentlets. A handled error is not a contentlet array; both blocks now complete with EMPTY.

Two of my own mistakes, both caught by tests

A wrong zero value. The bulk TS2564 pass turned @Input() showDiff: boolean into = false. The dotDiff pipe defaults to true and the store seeds showDiff: true, so false silently disabled diffing and broke 4 specs. "Boolean means false" does not hold when the consumer's default is not the zero value.

Spectator props keying. Renaming the key from the alias contentlet to the declared member $contentlet type-checks and then fails at runtime in 11 specs, because Spectator applies the alias while InferInputSignals types props by the member name. The cast is unavoidable; it now carries a comment saying so.

No production non-null assertions left behind

Bulk narrowing introduced 27 in edit-content and 9 in template-builder, each tripping @typescript-eslint/no-non-null-assertion — the rule that exists to discourage exactly that. All were reverted or converted:

  • edit-content: reverted, errors put back on the remaining count where they want real guards
  • template-builder: converted — two DialogService.open() results, three subGridOpts.children reads, one child.containers filter, and a resizestart handler that asserted a four-link GridStack chain optional at every step
  • edit-ema-ui: five in dot-favorite-page replaced with form.controls['x'], which the form always builds

Batch six: edit-content (#35974) and the two it was blocking (#35976, #35967)

edit-content is the largest project closed so far after ui: 101 production errors and 208
spec errors to 0
, with 27 phantom "inherited" errors removed at source. 112 suites / 2218 tests
unchanged; lint at its pre-existing 11-warning baseline.

Its tsconfig.spec.json carried explicit "strict": false and
"noPropertyAccessFromIndexSignature": false opt-outs, which were hiding 666 of the 944 errors
the specs really had.

The dominant pattern was not missing guards — it was declarations lagging behind code already
written for null.
Six signalMethod handlers plus five utilities and BaseWrapperField.formControl
all opened with a !x guard while declaring the argument non-nullable.
RelationshipFieldStore.initialize had three comments saying "contentlet is null in manual
translation" over a type that said otherwise.

InputSignal<T> is not covariant. Widening BaseWrapperField to InputSignal<T | null> took
the count 74 → 94: all 17 subclasses would have needed byte-identical input types, and 5 legitimately
differ. The base only reads them, so it declares Signal<T | null> — that is covariant.

Two latent bugs the compiler surfaced: the workflow sidebar's Select button is not disabled, so
confirming without choosing emitted undefined through an output<string>(); and the category field
wrote undefined into the form value for selected categories with no inode.

Four fixtures could never have matched the code: canLock mocked with the raw HTTP envelope when the
service maps response.entity; getByInode mocked with the scheme-grouped shape; isDialogMode
mocked in three specs after being deleted from the store; and MultiSelect.valuesAsString, which does
not exist on PrimeNG 21 — so expect(...).toEqual(undefined) asserted nothing.

Three of my own changes were caught by the tests, and in each case the test documented the intent
better than the type did, so I changed my code rather than the assertion: a guard in
onCommentSubmitted (the test is named "should still call addComment … even if identifier is
undefined"
, and the adjacent tests assert the opposite for the history handlers); formValues: {}
where a spec asserts null; and a contentType guard that aborted the locales flow because the
fixture never set one — the guard is right, so the fixture now provides one.

Nine shared-model members were widened to admit the null the endpoints actually send (workflow
metadata/status/scheme, contentlet lockedBy/lockedByName, content-type field
defaultValue/values/rendered, categories description). Every reader already tolerated it and
the model was the outlier — WorkflowTask.status was the only non-nullable member alongside
belongsTo, description and dueDate, all three already annotated. Blast radius re-measured
across all 12 strict projects after every one: 0 errors.

Two things deliberately not done. DotCMSContentlet.title is genuinely nullable — two tests are
named "without title" — but widening it lights up 10 errors across block-editor and edit-ema-ui,
both already closed, so it needs its own issue. And onWorkflowActionFired did not get an inode
guard: it carries a comment warning that one silently blocks saving new content.

Also recorded on #35974: libs/edit-content has no build target, so its templates have never
been type-checked and its strictTemplates is inert — the same gap as libs/block-editor.

The two projects it was blocking land here too. dotcms-binary-field-builder needed no source
changes. portlets-dot-query-tool-portlet needed three signal-mock casts, and its combination of
noImplicitReturns without strict caught a TS7030 I had introduced in edit-content — a guard
that returned bare where the other path returns an Observable teardown.


Test plan

edit-ema-ui (#35971) and template-builder (#35958)

  • tsc -p — 0 on lib and spec for both (from 119 and 141)
  • edit-ema-ui:test — 20 suites / 343 tests green; template-builder:test — 15 suites / 148 tests unchanged
  • Both projects' lint back at their clean baselines, with zero production non-null assertions added
  • template-builder's 27 inherited errors traced to mocks.ts in the lib build, not to utils-testing
  • After widening DotLayout.sidebar and the data-access payload signature: dotcms-models, data-access, ui, block-editor, edit-ema-ui, utils-testing, utils, global-store all still 0

dotcms-block-editor (#35973)

  • tsc -p — 0 on tsconfig.app.json, tsconfig.spec.json and tsconfig.editor.json, all three of which previously aborted on TS2688
  • nx run dotcms-block-editor:build — clean (it failed first, on a template defect tsc -p cannot see)
  • libs/block-editor unaffected: 0/0, tests unchanged at 16 suites / 37 by name
  • libs/new-block-editor unaffected: 0
  • edit-content:test still green at 112 suites / 2218 tests after removing the poisoning augmentation

block-editor (#35955)

  • tsc -p libs/block-editor/tsconfig.lib.json --noEmit — 0 errors (from 296)
  • tsc -p libs/block-editor/tsconfig.spec.json --noEmit — 0 errors (from 443); 442 own errors deduped across both
  • nx run block-editor:test — unchanged at 16 suites / 37 tests failing, verified by failing-test name: identical 38 entries before and after
  • nx run block-editor:lint — unchanged at 11 errors, all in files this branch does not touch (git diff origin/main confirms)
  • Five dependents re-measured against 82dbf4c9ad: zero new errors, three go to 0
  • No build target on this project, so tsc -p is the gate — stated on the issue rather than implied

dotcms-js

  • pnpm exec tsc -p libs/dotcms-js/tsconfig.lib.json --noEmit — 0 errors (from 38)
  • All six already-strict consumers build green: data-access, global-store, portlets-dot-analytics, portlets-dot-analytics-data-access, portlets-dot-locales-portlet, utils-testing
  • data-access typecheck: 106 → 68 errors, zero new
  • dotcms-ui typecheck clean (one pre-existing unrelated error)
  • dotcms-js lint went from 42 to 41 problems (still tag-excluded)

sdk-create-app

  • tsc --noEmit clean on lib and spec
  • nx run sdk-create-app:build / :lint / :test green
  • CLI smoke test: node dist/libs/sdk/create-app/index.js --help works
  • Negative test: reverting the DEBUG fix makes nx run sdk-create-app:build fail with TS4111 — confirming the build gate is real

sdk-analytics

  • tsc --noEmit clean on tsconfig.lib.json (from 18) and tsconfig.spec.json (from 29)
  • nx run sdk-analytics:typecheck / :lint / :build / :build:standalone green
  • nx run sdk-analytics:test — 15 suites, 314 tests passed. Since jest never type-checked these specs, this was the real regression check on the fixture edits
  • Negative test: a deliberate type error does not fail nx run sdk-analytics:build — this project's build is not a gate

sdk-angular

  • tsc -p tsconfig.spec.json --noEmit now completes a semantic pass at all (previously TS6053), 0 own errors
  • tsc -p tsconfig.lib.json --noEmit 0 own errors
  • nx run sdk-angular:lint clean; :build green
  • nx run sdk-angular:test unchanged at 21 suites / 234 tests — the guard that no file dropped out of the program
  • Negative test: a deliberate type error does fail nx run sdk-angular:build (TS2322, exit 1) — ngtsc gates this project

data-access

  • tsc -p tsconfig.lib.json --noEmit 36 → 0; tsc -p tsconfig.spec.json --noEmit 84 → 0
  • nx run data-access:lint clean; :test unchanged at 79 suites / 754 tests
  • Blast radius: all 7 strict dependents counted before and after — zero new errors, 218 removed, three went 36 → 0
  • Runtime guard on the widened services: dotcms-ui 820 tests and ui 2184 tests pass
  • nx affected -t build green for all 6 affected projects

Batch two

Batch three

  • libs/ui both configs 0 (122 and 427 before); lint clean; 81 suites / 820 tests unchanged
  • dot-plugins 733 → 0; new-block-editor 60 → 0; dot-analytics 42 → 0; content-drive-ui 59 → 0
  • Tests green and unchanged: ui 820, analytics 333, content-drive-ui 245, plugins 81, new-block-editor 57
  • After libs/ui landed, re-verified the ten already-closed projects plus dotcdn for regressions
  • Swept every remaining project's moduleResolution; dot-plugins was the only one misconfigured

Both

  • pnpm exec nx format:check --base=origin/main green
  • No new any / @ts-ignore / @ts-expect-error (verified by diff grep)

Note: neither sdk-create-app nor dotcms-js has usable tests. sdk-create-app has zero test files (passWithNoTests: true); dotcms-js has 3 spec files that do not run (skip:test, and tsconfig.spec.json fails on a pre-existing jasmine types error). A green :test means nothing for either — the real verification is compilation.


Correction: a verification false negative (review follow-up)

A review comment caught a real regression this PR introduced, and the reason it slipped through matters for how the numbers above should be read.

libs/utils-testing/tsconfig.lib.json declares "types": ["jasmine"], and that package is not installed. tsc therefore emits TS2688: Cannot find type definition file for 'jasmine' and stops before semantic checking. So tsc -p libs/utils-testing/tsconfig.lib.json --noEmit reports exactly one error no matter what the code does.

The utils section originally reported "utils-testing unchanged at 1 pre-existing error" as evidence of no regression. That measurement proved nothing — nothing was being type-checked. Running the same config with --types node reveals 33 errors, including a genuine TS2741 caused by retyping EMPTY_SYSTEM_FIELD to Omit<DotCMSContentTypeField, 'clazz'>: the mock at dot-content-types.mock.ts:71 spreads it and never supplies clazz.

Fixed by giving the mock clazz: DotCMSClazzes.TEXT; that config is now at 32 errors, all pre-existing and unrelated.

Because the mock has ~103 consumers whose tests do run in CI, the runtime-value change was verified rather than assumed — clazz went null (pre-PR) → absent (this PR) → TEXT:

  • FieldUtil.isRow / isColumn / isTabDivider compare for equality and return false for all three values.
  • There is no !field.clazz or field.clazz === null anywhere in the repo.
  • Test runs: default-value-property 7/7; dot-content-types-edit 545 passed across 48 suites; data-access 751 passed across 79 suites.

The data-access figures reported elsewhere in this PR (106 → 68 for dotcms-js, 68 → 36 for utils) are not affected — that project has no unresolved types entry, so those runs were doing real semantic checking.

core-web/CLAUDE.md now documents this masking behaviour so the next person does not repeat it.

Other two comments

  • sdk-create-app — the throw said "requires at least 1 retry", but retries is the total attempt count (for (i = 0; i < retries; i++)), so retries = 1 is one attempt and zero retries. Reworded to "attempt".
  • CLAUDE.md verify snippet — hard-coded libs/<project>/tsconfig.lib.json, which resolves for neither nested projects (libs/sdk/create-app, which has no tsconfig.lib.json) nor apps (tsconfig.app.json). Replaced with a <projectRoot> placeholder and both caveats.

Notes for reviewers

Three sibling issues in this rollout turned out not to need the work as written, and were resolved separately:

Closes #35971
Closes #35958
Closes #35973
Closes #35955
Closes #35966
Closes #35961
Closes #35959
Closes #35956
Closes #35953
Closes #35970
Closes #35969
Closes #35968
Closes #35965
Closes #35963
Closes #35962
Closes #35960
Closes #35954
Closes #35952
Closes #35951
Closes #35950
Closes #35949
Closes #35948
Closes #35947
Closes #35946
Closes #35945
Closes #35974
Closes #35976
Closes #35967
Closes #35944
Closes #35940
Closes #35939
Closes #35938
Closes #35935

nicobytes and others added 2 commits August 7, 2026 12:12
`sdk-types` needs no code change: `libs/sdk/types/tsconfig.json` has carried
`strict: true` plus the four extra safety flags since the library was created
(#31967), and `tsc -p tsconfig.lib.json --noEmit` passes with zero errors.

It is already enforced too. Because `tsconfig.lib.json` sets
`"declaration": true`, `@rollup/plugin-typescript` sits in the Rollup chain and
reports type diagnostics, so `sdk-types:build` fails on a strict violation —
verified by removing a constructor assignment and watching the build report
TS2564. CI builds every project via the `build-test` execution in
`core-web/pom.xml`, so the gate already runs on each PR. A dedicated
`typecheck` target would be redundant. `lint` does not catch this: ESLint
reports lint rules, not TS diagnostics.

What was actually missing is documentation, so the remaining 42 projects in
epic #35932 have a pattern to follow:

- Add a `## TypeScript Strict Mode` section covering the per-project flags,
  what enforces them, and the Vite exception (esbuild skips type checking,
  which is why the Nx Vite plugin infers a separate `typecheck` target).
- Fix the line that forbade `"strict": true` in project tsconfigs. It sat under
  the Jest config guidance but read as a blanket ban, contradicted
  `docs/frontend/TYPESCRIPT_STANDARDS.md`, and blocked the epic outright. The
  restriction now points at `tsconfig.spec.json`, which is what it meant.

Closes #35935

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add the standard per-project strict flags to `libs/sdk/create-app/tsconfig.json`,
following the pattern established in #36879 (dotcms-models). `tsconfig.base.json`
is left at `strict: false`.

Two errors surfaced, both from flags beyond plain `strict`:

- `src/index.ts:393` — `process.env.DEBUG` needs bracket access under
  `noPropertyAccessFromIndexSignature` (TS4111). It is the only `process.env.*`
  dot access in the project.
- `src/utils/index.ts:41` — `fetchWithRetry` tripped `noImplicitReturns`
  (TS7030). The loop returns on success and throws on the last attempt, but with
  `retries < 1` the loop never runs and the function fell through returning
  `undefined`. Its only caller already guarded with `if (res && ...)`, so nothing
  broke in practice, but the signature was lying. Throwing after the loop closes
  the gap and narrows the return type.

No build or CI wiring needed. The `@nx/esbuild:esbuild` executor type-checks
before bundling — `skipTypeCheck` defaults to false and is not overridden — and
CI already builds this project via `nx run-many -t build` (`build-test` in
core-web/pom.xml). The same build runs in the SDK release pipeline
(`cicd_release-sdk.yml` → `nx run-many --projects='sdk-*'`), so the flags are
enforced on every release.

Verified: tsc clean on lib and spec; `nx run sdk-create-app:build/lint/test`
green; `nx affected -t build,lint` green; `node dist/libs/sdk/create-app/index.js
--help` still works. Negative test — reverting the DEBUG fix makes
`nx run sdk-create-app:build` fail with TS4111, confirming the gate is real.

Closes #35938

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @nicobytes's task in 6m 14s —— View job


Code Review — PR #36957 (TS strict mode rollout)

Reviewed the runtime-affecting changes across data-access, dotcdn, dot-rules, utils, and the whole block-editor cluster (which has no test safety net). The overwhelming majority of the diff is faithful type-only work — !/?/?? annotations, bracket-notation index access, type guards — and the data-access/dotcdn/dot-rules changes are careful and correct (the dot-condition-group event refactor actually fixes a latent bug where event.type reached dot-rule.component.ts:387/405 as undefined). One genuine regression stands out.

New Issues

  • 🟡 Medium: core-web/libs/block-editor/src/lib/extensions/floating-button/plugin/floating-button.plugin.ts:126 — the maxWidth fallback of 250 is now defeated when the image node has no rendered <img>.
    • Before: maxWidth: image?.width - this.offset || 250 → when querySelector('img') returns null, undefined - 10 is NaN, and NaN || 250 falls through to 250.
    • After: maxWidth: (image?.width ?? 0) - this.offset || 250(undefined ?? 0) - 10 is -10, which is truthy, so || 250 never fires and tippy receives a negative maxWidth.
    • this.offset is 10 (line 67), so the image-missing path silently changed from 250-10. block-editor has no runtime test coverage, so this won't be caught by CI. Suggest maxWidth: image?.width ? image.width - this.offset : 250. Fix this →

Existing (from prior reviewer comments — still open)

  • 🟡 Medium: core-web/libs/utils/src/lib/shared/FieldUtil.ts:33 — as @oidacra noted, EMPTY_FIELD.defaultValue, .hint and .values moved from null to undefined. JSON.stringify drops undefined keys, so COLUMN_FIELD/ROW_FIELD/TAB_FIELD (which spread this) now omit those keys on the wire instead of sending explicit null when a layout is saved via DotFieldService. Assumption: Jackson treats absent-key and explicit-null identically for these object fields (usual case). What to verify: confirm the content-type layout save endpoint doesn't distinguish them, then either keep null for these three or add it to the documented change list.
  • 🟡 Medium: core-web/libs/dotcms-webcomponents/src/.../dot-material-icon-picker.tsx:22, dot-html-to-image.tsx:30, dot-time.tsx:77@oidacra flagged these @State/@Prop fields annotated ! while the component logic assigns/reads undefined (e.g. this.selectedSuggestionIndex = undefined, render() branching on !this.previewImg). Strict is not enabled for dotcms-webcomponents in this PR (groundwork only), so there's no current build impact — but per this PR's own !-for-runtime-injected / ?-for-optional rule these should be ?, and they'll error under strict once [10/44] Enable TS strict mode in dotcms-webcomponents #35943 flips it. Worth aligning now to avoid rework.

Notes (non-blocking, not flagged as bugs)

  • actions-menu.extension.ts:536 (command: (props) => execCommand({ ...props, customBlocks })) and the table insert Number(value['rows']) coercion are real runtime changes but both are fixes — the floating-actions path previously reached getCustomActions(customBlocks) with customBlocks undefined, and the number control emits strings. Correct.
  • dot-asset.service.ts:22 — the new Promise<Response>[] type is slightly optimistic given each promise ends in .catch((e) => e), but that .catch is pre-existing and runtime is unchanged, so it's a type-accuracy nit, not a bug introduced here.

Everything else in the diff checks out. Only the floating-button.plugin.ts:126 item is a regression this PR introduces; the rest are non-blocking.

@github-actions github-actions Bot added Area : Documentation PR changes documentation files Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries labels Aug 7, 2026
@nicobytes nicobytes changed the title 35932 enable strict mode refactor(core-web): enable TS strict mode in sdk-create-app + document the rollout (#35938, #35935) Aug 7, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR opts the sdk-create-app library into the workspace’s incremental TypeScript strict-mode rollout (issue #35932), and adjusts docs/runtime code to align with stricter typing and clearer failure modes.

Changes:

  • Enabled strict TypeScript compiler flags for core-web/libs/sdk/create-app via its project tsconfig.json.
  • Updated fetchWithRetry to throw when misconfigured with < 1 attempts to avoid an implicit undefined return path.
  • Updated strict-mode rollout documentation and adjusted DEBUG env access to bracket notation for noPropertyAccessFromIndexSignature.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
core-web/libs/sdk/create-app/tsconfig.json Enables strict compiler options at the project level for the strict-mode rollout.
core-web/libs/sdk/create-app/src/utils/index.ts Adds an explicit throw path for invalid retries values in fetchWithRetry.
core-web/libs/sdk/create-app/src/index.ts Switches DEBUG env access to process.env['DEBUG'] for strict-mode compatibility.
core-web/CLAUDE.md Documents the strict-mode rollout procedure and clarifies portlet tsconfig guidance.

Comment thread core-web/libs/sdk/create-app/src/utils/index.ts
Comment thread core-web/CLAUDE.md Outdated
Add the standard per-project strict flags to `libs/dotcms-js/tsconfig.json`,
following the pattern from #36879 (dotcms-models), and resolve the 38 errors
they surface across 11 files. `tsconfig.base.json` stays at `strict: false`.

Notable type corrections rather than mechanical silencing:

- `Auth.loginAsUser` was typed `User` but the code has always passed `null`
  when nobody is impersonating, and every consumer already guards with
  `auth.loginAsUser || auth.user`. Corrected to `User | null`.
- `StringUtils.getLine` and `HttpRequestUtils.getQueryStringParam` both
  document "null if it does not exist" but were typed `string`. Corrected.
- `RoutingService.getPortletURL` returns `Map.get()`, so `string | undefined`.
- `SiteService.switchSiteById` emits `of(null)` when no site is found, so
  `Observable<Site | null>`. Its one consumer already handles null.
- `ResponseView` now models `HttpResponse.body` as nullable instead of
  assigning `null` into a non-nullable field inside a `try/catch` that could
  never throw. The dead try/catch is removed.
- `LoginService.urls` is typed by inference instead of `Record<string, string>`,
  which keeps dot access valid and gives each endpoint a named property.

Two definite-assignment assertions were used, each with a TODO: `_auth` and
`selectedSite` are assigned during init but not in the constructor. Modelling
them as `| undefined` is the truthful type, but their public getters (`auth`,
`currentSite`) are consumed by already-strict projects, so widening them is a
public-API change that belongs in its own issue.

No new `any`, `@ts-ignore`, or `@ts-expect-error`.

Verified:
- `tsc -p libs/dotcms-js/tsconfig.lib.json --noEmit` — 0 errors
- All six already-strict consumers build green (data-access, global-store,
  portlets-dot-analytics, portlets-dot-analytics-data-access,
  portlets-dot-locales-portlet, utils-testing)
- `data-access` typecheck went from 106 errors to 68, with zero new errors
  introduced — the honest types upstream remove noise downstream
- `dotcms-ui` typecheck clean apart from a pre-existing missing
  `dotcms-webcomponents/loader` dist
- `nx format:check` green; dotcms-js lint went from 42 to 41 problems

Note: this project has no `build` target and is tag-excluded from lint and
test, so nothing in CI verifies these flags. That was an explicit scoping
decision — no `typecheck` target or CI gate was added. See
`specs/35939-dotcms-js-strict-mode/spec.md`.

Closes #35939

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nicobytes nicobytes changed the title refactor(core-web): enable TS strict mode in sdk-create-app + document the rollout (#35938, #35935) refactor(core-web): enable TS strict mode in dotcms-js and sdk-create-app + document the rollout (#35939, #35938, #35935) Aug 7, 2026
nicobytes and others added 3 commits August 7, 2026 14:16
Add the standard per-project strict flags to `libs/utils/tsconfig.json`,
following the pattern from #36879 (dotcms-models), and resolve the 32 errors
they surface across 3 files. `tsconfig.base.json` stays at `strict: false`.

The flags also propagate to `tsconfig.spec.json`, which surfaced 17 further
errors in the spec files (baseline was 0). Those are fixed here too rather
than left as a regression.

Notable changes:

- `EMPTY_FIELD` assigned `null` to 18 members that `DotCMSContentTypeField`
  declares non-nullable. Replaced with zero values of the declared types.
  Nothing compares those members to `null` strictly — consumers use falsy
  checks such as `isNewField`'s `!field.id` — so `''`, `0` and `false` behave
  identically at runtime.
- `clazz` has no zero value (`DotCMSClazz` is a union of concrete Java class
  names), so `EMPTY_FIELD` and `EMPTY_SYSTEM_FIELD` are now typed
  `Omit<DotCMSContentTypeField, 'clazz'>`. They are partial templates, not
  valid fields, and the type now says so. The derived `COLUMN_FIELD`,
  `ROW_FIELD` and `TAB_FIELD` already supply their own `clazz`.
- `getFieldsWithoutLayout` used a truthy `.filter()` that does not narrow the
  optional `row.columns`. Replaced with a type predicate, which clears the
  TS2532 and both TS2769 errors without a cast.
- `ellipsizeText` accepted `null`/`undefined` at runtime — its own guard and
  its tests document that — but declared `string` and `number`. Widened to
  match, with an explicit `limit == null` check so the later comparisons
  narrow.
- `fallbackErrorMessages` typed `{ [key: number]: string }`, mirroring the
  identical declaration already in `libs/data-access/.../dot-upload.service.ts`.
- `dot-utils.ts` uses bracket access for the six `DotCMSContentlet`
  index-signature reads in `getImageAssetUrl`.

No new `any`, `@ts-ignore`, or `@ts-expect-error`. The nine `as unknown as`
casts added are all in spec files, on inputs the tests deliberately pass as
invalid, matching the idiom those files already used.

Verified:
- `tsc -p libs/utils/tsconfig.lib.json --noEmit` — 0 errors (from 32)
- `tsc -p libs/utils/tsconfig.spec.json --noEmit` — 0 errors (from 17)
- `data-access` typecheck went from 68 errors to 36, zero new
- `utils-testing` unchanged at 1 pre-existing error (missing jasmine types)
- `dotcms-ui` typecheck clean apart from a pre-existing missing
  `dotcms-webcomponents/loader` dist
- `nx format:check` green

Note: `utils` has no `build` target and is tag-excluded from lint and test, so
nothing in CI verifies these flags — the same accepted trade-off as #35939.

Closes #35940

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nicobytes nicobytes changed the title refactor(core-web): enable TS strict mode in dotcms-js and sdk-create-app + document the rollout (#35939, #35938, #35935) refactor(core-web): enable TS strict mode in utils, dotcms-js and sdk-create-app + document the rollout Aug 7, 2026
nicobytes and others added 2 commits August 7, 2026 16:20
Addresses three review comments on #36957.

1. `dot-content-types.mock.ts` — real regression, now fixed.

`dotcmsContentTypeFieldBasicMock` spreads `EMPTY_SYSTEM_FIELD`, which #35940
retyped to `Omit<DotCMSContentTypeField, 'clazz'>`, leaving the mock without a
required property (TS2741). It now supplies `clazz: DotCMSClazzes.TEXT`; callers
that care already override it.

Why the original verification missed it: `libs/utils-testing/tsconfig.lib.json`
declares `"types": ["jasmine"]` and that package is not installed, so tsc emits
`TS2688: Cannot find type definition file for 'jasmine'` and stops before
semantic checking. The "1 error before, 1 after" measurement reported in #35940
therefore proved nothing — nothing was being checked. Running with
`--types node` reveals 33 errors, including the TS2741. It is 32 after this fix.

Verified the runtime-value change, since the mock has ~103 consumers whose
tests do run in CI: `clazz` went `null` (pre-PR) → absent (#35940) → `TEXT`.
`FieldUtil.isRow`/`isColumn`/`isTabDivider` compare for equality and return
false for all three, and there is no `!field.clazz` or `=== null` check
anywhere. Test runs: `default-value-property` 7/7, `dot-content-types-edit`
545 passed across 48 suites, `data-access` 751 passed across 79 suites.

2. `sdk-create-app/src/utils/index.ts` — the throw said "requires at least 1
retry", but `retries` is the total attempt count (`for (i = 0; i < retries)`),
so `retries = 1` means one attempt and zero retries. Reworded to "attempt" and
the ambiguity noted in the comment.

3. `core-web/CLAUDE.md` — the verify snippet hard-coded
`libs/<project>/tsconfig.lib.json`, which resolves for neither nested projects
(`libs/sdk/create-app`, which has no `tsconfig.lib.json`) nor apps
(`tsconfig.app.json`). Replaced with a `<projectRoot>` placeholder plus the two
caveats, a reminder that `tsconfig.spec.json` inherits the flags, and a warning
about unresolved `types` entries masking all semantic diagnostics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`sdk-uve` needs no change for [08/44]. The six strict flags have been in
`libs/sdk/uve/tsconfig.json` since the library was created (`277cbbc8f7`,
#31242, Feb 2025) as a verbatim copy of `sdk-client`'s config, `tsc --noEmit`
is clean on both lib and spec, and there are zero `any`, `@ts-ignore` or
non-null assertions across 4518 lines.

It is also genuinely enforced, which is what separated `sdk-types` from
`dotcms-js` and `utils`. `rollup.config.cjs` sets `compiler: 'babel'`, but that
governs only transpilation — `@nx/rollup`'s `withNx` always inserts a
TypeScript plugin with `check`/`noEmitOnError` tied to `skipTypeCheck`, which
this project does not set. Two of the three type-checking paths run in CI, and
the `build-test` execution in `core-web/pom.xml` has no `<skip>` element, so it
cannot be turned off.

Issue closed as completed with the evidence; not linked to PR #36957 since
there is no diff and that PR did not resolve it.

Also records an incidental finding, left unfixed: `tsconfig.base.json:104`
maps `@dotcms/uve/types` to a file that does not exist, and nothing imports it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`sdk-client` needs no change for [09/44]. The six strict flags are already in
`libs/sdk/client/tsconfig.json`, `tsc --noEmit` is clean on both lib and spec,
and there are zero `any`, `@ts-ignore` or non-null assertions across 9600 lines
of production source.

Enforcement is unambiguous here, unlike the sibling projects that needed an
argument: `rollup.config.cjs` sets `compiler: 'tsc'` against `tsconfig.lib.json`
with no `skipTypeCheck`, so the build compiles with tsc directly against the
strict config. `tags` is empty and the `build-test` execution in
`core-web/pom.xml` has no `<skip>` element, so that build runs on every PR and
gates every SDK release.

Issue closed as completed with the evidence; not linked to PR #36957 since
there is no diff and that PR did not resolve it.

Also records an emerging pattern for the remaining issues: every `libs/sdk/*`
project checked so far is already strict and already enforced — they share a
tsconfig lineage (sdk-uve's config is a verbatim copy of this one) and all build
through Nx executors that type-check. The unfinished work is concentrated in
the non-SDK libraries and the apps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nicobytes and others added 6 commits August 18, 2026 14:20
`dot-binary-file`, whose two private fields were declared non-nullable while the
class treats both as absent.

`errorType` is null whenever the field is valid, which is why `errorMessageMap` has
no entry for that case — `getErrorMessage` now reports `string | undefined` and
`shouldShowErrorMessage` coerces instead of returning the message itself.

`binaryTextField` is resolved from the DOM after render, and `handleDelete` already
carries a comment saying it can be null and re-queries for it. The five writes to it
now go through one guarded helper rather than a bare property write inside a `try`
that was silently absorbing exactly that case.

`DotFieldValueEvent.value` and `getTagError`'s message both admit the absent case
that their callers already produce — `setValue()` with no argument emits a cleared
field, and `getTagError` guards its own message with `isStringType`.

Build still passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#35943)

`dot-contentlet-thumbnail.componentWillLoad` assigns a `||` chain to `renderImage`,
which is declared `boolean`. Adding `image?: string` to `DotContentletItem` made that
chain `string | boolean`: the read used to go through a bracket index on a property
the model did not declare, which yielded `any` and hid it. Coerced with `!!`, which is
how `renderImage` is used at the only place that reads it.

This has been failing since 5a56f8e. I claimed "build still passes" on that commit
and the three after it on the strength of a grep that matched `build finished` in the
output; a clean run reports `build failed` and exits non-zero. Verified here by exit
status instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five repeated shapes swept across the field components, all the same mistake in
different clothes: a value whose falsy case must *omit* a JSX attribute, declared as
though it could only ever be present.

- 7 × `tabIndex={this.hint ? 0 : null}` and 2 × `selected={… ? true : null}`.
- 7 attribute helpers (`isDisabled`, `shouldBeDisabled`, `getDisabledAtt`,
  `getRequiredAttr`) declared `: boolean` while returning null. They exist precisely
  so the attribute is dropped rather than rendered `false`, which only
  `boolean | undefined` can say.
- 6 × `shouldShowErrorMessage` returning the message instead of a boolean.
- 4 × `getDotOptionsFromFieldValue(checkProp(…))`, coalesced like the assignments
  before them.
- `dotValidateDate` / `dotValidateTime` declared `: string`; both doc comments already
  said "otherwise null" and only the annotation disagreed.

Build verified by exit status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Runtime-keyed maps, the shared date/error utils, and `dot-autocomplete`.

Four object literals were being indexed by values only known at runtime — a DOM tag
name, a content-type field type, an HTTP status, a `KeyboardEvent.key` — and every
call site already branched on the lookup missing (`process ? process(value) : value`,
`getErrorMessage(message) || fallbackErrorMessages[status]`). Each now declares the
index signature that says so, and the two double-lookup sites hoist to a local so the
narrowing survives.

`getId` called `slugify` on its own already-slugified result, which is a no-op and was
the only reason a nullable reached the second call. `isStringType` now takes `unknown`,
which is what a type check is for. `dotParseDate` coalesces both halves — the
validators return null for a string that is not a date, and `DotDateSlot` declares
plain strings, with empty being what every reader already treats as unset.

`@tarekraafat/autocomplete.js` ships no types; added a shim next to the existing
Angular/PrimeNG ones declaring just the constructor `dot-autocomplete` uses.

Its `getInputElement`/`getResultList` both return `querySelector` results and are
absent until the suggestion list has rendered — `clearList` already guarded for that
while three siblings did not. `enteredList` also read `attributes['hidden']`, which
`NamedNodeMap` does not support; `getNamedItem` does.

Build verified by exit status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two real bugs surfaced here, both from the same mechanism: a declaration that was
wrong, hiding a mistake underneath it.

`DotUploadService.uploadFile` passed `maxSize` as the **second** argument to
`uploadBinaryFile(data, progressCallBack?, maxSize?)`. So `maxFileLength` has never
reached the temp-file API and the progress callback was handed a string. A
wrong-position argument, not a design choice — both the local and the parameter are
named `maxSize`. In practice the client-side `file.size <= maxSize` check in
`dot-form` rejects oversize files first, which is why nobody noticed.

`DotAssetService.create` declared `Promise<DotCMSContentlet[] | DotHttpErrorResponse[]>`
but `throw`s its error array, so the error arm is never *resolved* — which is why every
consumer reads it from a `.catch`. Narrowed to what it actually returns. Its
per-request `.catch((e) => e)` still resolves a rejected fetch *as* its error, so
`res.json()` can throw and the consumer gets a `TypeError` instead of
`DotHttpErrorResponse[]`; noted in place, since that one is a behaviour fix.

Also removed a `debugger` statement that was shipping in
`dot-asset-drop-zone.createDotAsset`.

The rest: `dot-form.uploadFile` resolves null on both failure paths and its caller
already reads `tempFile && tempFile.id`; `getFieldsFromLayout` and
`pipedValuesToObject` now report the empty cases their guards were written for; and
`dot-card-view`'s two selection helpers accept the optional `value` prop they already
test with `value && typeof value === 'string'`.

Build verified by exit status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…35943)

All six flags are now in `libs/dotcms-webcomponents/tsconfig.json`, which Stencil
reads, so they are enforced by `build`. 295 errors across 42 files down to 0.

## Two compilers, two answers

The last error only existed for one of them. This project is type-checked twice — by
the workspace's `tsc` (6.0.3) and by Stencil, which bundles its own 5.8.3 — and
TypeScript 6 re-declared `Node.textContent` as an asymmetric accessor
(`get(): string`, `set(value: string | null)`). So `keys[i].textContent.replace(...)`
in `dot-key-value` is clean under 6 and `Object is possibly 'null'` under 5.8. The
project read 0 on `tsc -p` while the Stencil build still failed.

Where two compilers check the same sources, the build is the gate. Recorded in
`core-web/CLAUDE.md` along with the exit-status lesson from the four commits I
mis-verified by grep.

## The last of the tail

- `flatpickr`'s `DateOption` is `Date | string | number`, so `maxDate: null` matched
  none of its three overloads — TypeScript reported the selector argument, which sent
  me looking in the wrong place. An absent key is how flatpickr means "no limit".
- `dot-card-contentlet.item` is the third prop declared optional while `render` reads
  through it unguarded (`contentlet.language`), after both thumbnail components.
- `dot-autocomplete`'s `@Event() selection` was declared `EventEmitter<string>` and is
  never emitted: the event comes from autocomplete.js on the inner input and bubbles,
  carrying the library's own `SelectionFeedback` — which is what
  `dot-tags.onSelectHandler` reads. The declaration only ever existed to type the
  `onSelection` prop, and now types it correctly.

`dotcms-webcomponents:test` still cannot run (Stencil supports Jest 27, the workspace
is on 29). That is pre-existing and unchanged; it is why the build is the only gate.

Closes #35943

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nicobytes and others added 18 commits August 18, 2026 15:50
)

`portlets-dot-experiments-portlet` reported 5 spec errors *before* any strict flag is
added, so they were not part of the rollout — they had simply never been measured. The
project has only a `test` target, and `test` does not type-check.

- `let dynamicState = { healthStatus: HealthStatusTypes.NOT_CONFIGURED }` pinned
  `healthStatus` to that one literal from its initializer, so the reassignment each
  describe block makes was rejected. Annotated instead.
- `spectator.query(...)?.querySelector<HTMLButtonElement>('button') ?? host` widens the
  narrowed result straight back to `Element` via the fallback, losing `.disabled` (×2).
- Two `variants.DEFAULT` reads needed bracket access under
  `noPropertyAccessFromIndexSignature`, which the `variants['111']` line beside them
  already used.

Both configs are at 0 now, which is the real baseline for the strict work: with
`strict` on it is 93 lib + 289 spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

`dot-experiments-configuration-store.ts` was 24 of the project's 93 lib errors and one
root cause: the state interface declared eight members non-nullable while the initial
state seeds `undefined`/`null` for five of them, and every derived observable declared
away the null its own body returns (`experiment.goals ? {…} : null` typed
`Observable<Goals>`).

Widening those cascaded outward, which is the point — the store is now at 0 and the
components that consume its view models report the nullability they were always
handed. Total went 93 → 81 with the store's 24 absorbed.

Things the widening surfaced, all of which the code already half-knew:

- `disableStartExperiment` read `experiment?.trafficProportion.variants.length` — the
  `?.` guards only the first hop, so a loaded-but-incomplete experiment still threw.
- `setStartLabel` destructures with a `{ scheduling: null }` fallback and then read
  `experiment.scheduling` anyway, going around it.
- The four step updaters spread `state.experiment` unguarded, which builds a partial
  `DotExperiment` from `{ ...undefined }`.
- `getMenuItems` gated every entry on `experiment?.status` except the two enterprise
  ones, which were visible without an experiment and would throw on click. It now
  returns no items when there is nothing to act on.
- `AllowedConditionOperatorsByTypeOfGoal` maps 2 of the goal types, so indexing it by
  `GOAL_TYPES` misses for the others — `Partial<Record<…>>` says so.

Also fixed `processExperimentConfigProps`, which is fed the store's `configProps`
before the route resolver has run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…35964)

Three mechanical clusters.

**21 × TS2564.** Angular fields populated in `ngOnInit` / a `@Watch` rather than the
constructor — `form`, `componentRef`, `scheduling`, `itemList`. Definite assignment,
which is what the framework contract already means here.

**`handleSidebar(status: StepStatus)` in four components.** Its body opens with
`if (status && status.isOpen && …)`, so it was written for an absent status all along;
only the parameter disagreed once the step observables started reporting their null.

**11 × TS7006 in the chart.js layer.** The options file and the HTML-legend plugin
never imported chart.js's own types, so every callback parameter was implicit any.
Typing them surfaced two real gaps the untyped version hid:

- `chart.options.plugins.legend.labels.generateLabels(chart)` — every hop of that
  chain is optional in chart.js's option types.
- `LegendItem.datasetIndex` is optional (a legend entry need not map to a dataset),
  and it was being passed straight to `setDatasetVisibility(index: number)`.

Also four `confirmationService.confirm({ target: event.target })`: PrimeNG's
`Confirmation.target` is a non-null `EventTarget` while `Event.target` is nullable,
and the property is optional — undefined just omits the anchor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… jstat (#35964)

`dot-experiments-reports-store` repeats the configuration store's shape exactly: the
state is honestly `| null` but every projector's declaration was not. Fixing it
surfaced three things:

- `BayesianNoWinnerStatus.includes(results?.bayesianResult?.suggestedWinner)` — the
  winner is absent until the bayesian result arrives, and an absent one is not a
  winner, which is the `null` the status list already produced.
- `getPromotedVariant$` is a `find`, so it is `undefined` when nothing is promoted,
  never `null`.
- `dotMessageService.get(key, suggestedWinner?.variantDescription)` passed a possibly
  absent value into `...args: string[]`. Spread instead — the no-winner legends take
  no argument at all.

`dot-experiments-list-store` had the same two null-seeded state members and the same
`route.parent` chain as its sibling.

`Object.keys(DotExperimentStatus).forEach(key => DotExperimentStatus[key])` needed
`keyof typeof`: `Object.keys` erases to `string[]` even though every key came from
that enum.

The chart.js layer again: `LegendItem.fillStyle`/`fontColor` are `Color | undefined`
and were assigned straight to CSS style properties, and two tooltip callbacks did
arithmetic on `TooltipItem.label`, which is a string.

`jstat` ships no types and has no `@types` package; added a narrow shim declaring only
the beta distribution `dot-experiment.utils` uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
93 → 0 on `tsconfig.lib.json`. The spec side fell from 289 to 211 on its own, since
most of those errors were the production types leaking in.

Two real defects, both surfaced by a flag rather than by a test:

**`getVariantUrl`'s `finally` read an unassigned `url`.** The `try` parses an absolute
URL and the `catch` retries as relative; `finally` then called `url.toString()`. If the
catch block's own `new URL` throws too — a genuinely malformed path — `url` was never
assigned and the `finally` replaced the real failure with "url is undefined". Both
branches assign it, so returning after the try/catch says the same thing and lets a
real failure propagate.

**`checkIfExperimentDescriptionIsSaving` returned its `&&` chain**, so with no sidebar
step it yielded that falsy value rather than a boolean, while every consumer declares
`Observable<boolean>`.

The rest is one shape repeated: a value that is legitimately absent, declared as though
it never were.

- `getBayesianVariantResult` is a `find`, so a variant the bayesian run did not cover
  has no result — and `getConversionRateRage` / `getProbabilityToBeBest`, which consume
  it, each already carry a `noDataLabel` for precisely that case.
- The three step components declare their view model inline and had drifted from what
  the store publishes.
- `FormControl<number>(value, { nonNullable: true })` cannot take a null seed;
  `FormControl<string>('')` without `nonNullable` is a `FormControl<string | null>`.
- `AbstractControl.get()` reports a missing control at seven call sites.
- `_select` in `dot-experiment-options-item` is injected `{ optional: true }`, so it is
  null when the directive is used outside its host.
- `jstat` needed a shim; `LegendItem.fillStyle`/`fontColor` are `Color | undefined` and
  were assigned to CSS style properties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Widen DotCMSWorkflowStatus.scheme to `DotCMSWorkflow | null` to match the
API, and fix the resulting strict-mode fallout across specs (non-null
assertions, typed generics, PrimeNG event casts, and a stale user mock
field).
…lied on a defect (#35964)

80 non-null assertions inserted from tsc's exact line/column, plus three test setups
that my `setScheduling`/`setTrafficAllocation`/`setTrafficProportion` guards broke — and
they are worth spelling out, because the guards are right and the tests were wrong.

Each of those updaters used to spread `state.experiment` unguarded, so with nothing
loaded `{ ...undefined, scheduling }` produced a *partial* `DotExperiment` carrying one
field and no `id`, `name` or `status`. Three tests patched the store without loading an
experiment first and asserted on the result, which only worked because of that. Every
sibling test in the same describe calls `loadExperiment` first, and so does the app —
these sidebars only open for a loaded experiment. Setup corrected; no assertion relaxed.

One more test-only fix: `mockProvider(DotExperimentsService)` auto-mocks `start` to
return undefined, and `startExperiment` pipes off the result — an unhandled rejection
fired after the assertion had already passed.

## Two method notes

My batch-narrowing script inserted `!` at the end of the surrounding member chain rather
than after the expression tsc names, producing `expect(x.$title()!!!!!!!!!)`. Reverted
and rewritten to key off the quoted name in the message and verify it sits at the
reported column. It refuses the "Object is possibly 'null'" form, where tsc points at
the start of the object expression and its extent is not recoverable from the text — the
remaining 26 of those are hand work.

While that was broken it also introduced a syntax error, and **a syntax error suppresses
every semantic diagnostic in the program**: the count read 4 when it was really 184. Any
`TS1xxx` in the output means the number after it is meaningless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…35964)

`strict` is now on in `libs/portlets/dot-experiments/portlet/tsconfig.json`, joining the
five flags that were already there. Both configs read 0 with no CLI overrides, tests and
lint pass, and the nine strict consumers of the models this touched are unchanged.

The project also had **5 type errors before any flag was added** — it has only a `test`
target, and `test` does not type-check, so nothing had ever looked.

## The 104 spec errors

Mostly one shape: a Spectator `query`/`queryLast` result, or an `AbstractControl.get()`,
dereferenced without narrowing. `!` is the sanctioned idiom in specs — the rule is off
for `*.spec.ts` repo-wide — and the assertions are honest: a click on nothing, or a form
missing a control the component's own `initForm` built, is a test failure and not a case
to handle.

The rest were fixtures drifting from what the store now publishes: `experimentStatus`
and `promotedVariant` are `undefined` (an absent status, a `find` with no match), not
null; `menuItems` is `[]`; `pageSate` is absent until the parent route resolves. Three
observables that emit null before their data loads had their emission destructured in
the subscribe pattern, which cannot carry an assertion — those now destructure from an
asserted parameter.

Two casts document a deliberate out-of-contract input rather than hide one: an
error-path test passing null where the payload type wants a value, and a test building
an experiment without a `trafficAllocation` to exercise the gray indicator.

## A method correction

My by-site substitution pass over-applied twice before I got it right: first a broad
regex that added ~70 assertions to `spectator.query` assignments that were already fine,
then a `dispatchMouseEvent` pattern whose `[^)]*` stopped at the inner `byTestId(...)`
and wrote `!!!` inside it. Both reverted. The version that stuck only edits lines tsc
actually reported, and I checked the diff for `(!`, `!!` and `[!` before committing.

Closes #35964

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…35932)

`libs/portlets/edit-ema/portlet/tsconfig.lib.json` compiled a spec from
another library — `data-access`'s `dot-favorite-contenttype.service.spec.ts`
— as one of its own production sources. Under that config's `types: []`
there are no jest globals, so it read as 136 errors. It was added by
"Redesign Content Palette" (#33660, d018b4b), but the palette lives in
`edit-ema/ui`, so the entry landed on the wrong project and has never done
anything: nothing under `edit-ema/portlet` references `FavoriteContentType`,
and jest collects by rootDir, so its test target never saw the file either.
`data-access:test` still collects and runs it, as it always has.

The paired entry in `tsconfig.spec.json` is dead for the same reason, though
it cost nothing — that config sets `types: ["jest", "node"]`, so the spec
compiled clean there. Removed for consistency, not for a count.

`libs/dot-rules/tsconfig.lib.json` excluded `**/*.spec.ts`, which does not
match `Rule.it-spec.ts` — the glob needs the literal `.spec.ts`. So three
integration specs compiled as production sources: 32 errors, and 3 more from
`src/test-setup.ts`, which the exclude list missed because it still named
`src/test.ts`, a file that no longer exists.

    edit-ema/portlet  tsconfig.lib.json    188 → 52
    edit-ema/portlet  tsconfig.spec.json    22 → 22
    dot-rules         tsconfig.lib.json     35 → 0

Those are honest baselines now, not fixes: no source changed. dot-rules is
at 0 under its current config only because none of the six strict flags are
on yet — that is the next step.

Worth flagging separately: the three `.it-spec.ts` files are now checked by
nothing, which is the truthful state rather than a new gap. They import
`ReflectiveInjector`, which Angular has removed, so they cannot run as
written and have not for years — deleting them is a call for a follow-up.

Verified: dot-rules 12/12 tests, edit-ema/portlet 1397 passed / 5 skipped,
lint clean on both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…35957)

`rule-event.model.ts` had every payload field optional, which is why
`DotRuleEngineContainerComponent` alone accounted for 116 of the project's
276 strict-mode errors — 74 of them `Object is possibly 'undefined'` on
`event.payload.rule`, `.condition`, `.conditionGroup` and `.ruleAction`.

The optionality was not caution, it was a design collision. These events
bubble in two stages: an inner component emits the entity it owns, then
`DotRuleComponent` re-emits with the `rule` — and where the child cannot know
it, the `conditionGroup` — attached, and only then does the container see it.
One interface described both ends, so it fitted neither: the container
dereferenced fields the type said might be absent, while the inner emitters
needed `as ConditionActionEvent` casts to compile at all.

Split into `*EmitEvent` types for stage one and container-facing types for
stage two, each field required exactly where a handler reads it. Two things
that fell out of doing it:

- `DotRuleComponent` declared its incoming parameters as
  `{ type: string; payload: { value: string; index: number } }` for five
  handlers, dropping the `ruleAction` / `condition` the container goes on to
  dereference. `Object.assign` smuggled it back at runtime. Those handlers now
  take the real emit type and build the outgoing event explicitly, which also
  stops this component writing into an object its child still owns.
- `DotConditionGroupComponent` emitted `type` *inside* `payload`, where no
  handler reads it, and omitted the top-level `type` the interface has always
  required. That is what the two `as` casts were hiding. Moved to the top
  level; the container ignores `type` on both events, so nothing changes.

Also `export type` on the container's backward-compatibility re-export: with
`isolatedModules` on, re-exporting an interface without it is `TS1205`, which
was 4 of the spec config's errors. Nothing outside `dot-rules` imports these
types — only `DotRulesModule` — so the shim is inert either way.

    tsconfig.lib.json    276 → 180
    tsconfig.spec.json   286 → 186

Counts measured with the six flags passed on the CLI; they are not in
`tsconfig.json` yet. Tests 12/12 and lint clean.

Noted, not changed: the container's `onDeleteConditionGroup` is unreachable —
nothing binds it — as is `DotConditionGroupComponent`'s `deleteConditionGroup`
output, which has a different type again. `ParameterChangeEvent` and
`TypeChangeEvent` were declared in this file and never referenced anywhere;
they are gone with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `Object.assign(this, iRule)` construction idiom is why `Rule.ts`,
`ServerSideFieldModel.ts`, `input.model.ts` and `base.model.ts` carried 111 of
the project's strict-mode errors between them: every field was declared
non-nullable and populated by a copy the compiler cannot see through, from
interfaces whose fields are all optional.

Widened what is genuinely absent rather than asserting it away. `key` is
`string | null` on `BaseModel`, `ConditionGroupModel` and `RuleModel` —
`isPersisted()` is `this.key != null`, so an unsaved entity without a key is
the normal case, not an edge one. `name` on `RuleModel` likewise: `DEFAULT_RULE`
sets it to `null` outright. Where a field is filled by a known collaborator
immediately after construction (`_type` via the `type` setter, `_opt` by
`RuleService`'s type loader) it takes a definite-assignment `!` with a comment
naming who assigns it.

Six things this turned up that were not typing problems:

- `ServerSideFieldModel.isValid()` reached `paramDef.inputType['options'][value]`
  for any parameter named `comparison`. `options` is declared on
  `DropdownInputModel`, not on the base, so for a non-dropdown that read
  `undefined` and then indexed it — a `TypeError` inside a `.some()` callback,
  outside the surrounding try/catch. Comparison inputs are dropdowns in
  practice, which is the only reason it never fired. Now narrowed with
  `instanceof`.
- `loadActionTypes()` and `loadConditionTypes()` each had a cached branch
  returning `observableFrom(this._*TypesAry)`, which emits array *items* where
  the caller destructures an array. Both arrays were initialised to `[]` and
  never written to, so `.length` was always 0 and the branch was unreachable.
  Removed the branch and the two fields rather than fixing a type on dead code.
- `ActionModel.isValid()` and `ConditionModel.isValid()` returned nothing from
  their catch blocks — declared `boolean`, actually `undefined`. Callers read it
  as falsy either way; they now say `false`.
- `ServerSideFieldModel.isValid()` returned `this._type.key !== 'NoSelection'`
  ANDed with `this._type.key`, i.e. `string | boolean`.
- `InputDefinition.verify()` was declared `{ [key: string]: boolean }` and
  returns `ValidationErrors | null`. Its one caller tests `verify(value) == null`,
  so the passing case was the one the type could not express.
- `getPageIdFromUrl()` was declared `string` and has a `return null`.

Two dead declarations went with it: `i18nBaseKey` on `DropdownInputModel` and
`RestDropdownInputModel` (never assigned, never read — both call sites read it
off `ParameterDefinition`, where it is assigned) and `priority` on
`ServerSideTypeModel`. `ParameterDefinition`'s five fields became constructor
parameters so the class cannot exist half-filled; `fromJson` is still the only
way it is built.

    tsconfig.lib.json    180 → 111

Tests 12/12, lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Project 24 of 44 in the strict-mode rollout (#35932). All six flags were
absent from `libs/dot-rules/tsconfig.json`; with them on, the project measured
276 lib and 286 spec errors. Both configs are now at 0 with no CLI overrides.

The spec figure was mostly an echo: 280 of those 286 were the same production
sources pulled in through imports. Only six errors actually lived in spec
files, and five of them were one drifted fixture.

This last commit is the remaining components plus the small services, and the
flags themselves. Notable, beyond narrowing:

- `ActionService.updateRuleAction` and `ConditionService.save` both handled an
  unsaved model by calling their own create method and *discarding* the
  observable — nothing subscribed, so the POST never fired, and each then fell
  off the end and handed its caller `undefined` to subscribe to. Their sibling
  `ConditionGroupService.save` has always had the `return`. Now all three do.
- `_handle403Error` was declared `(e: CwError)` and opened by casting its own
  parameter to `HttpErrorResponse`. The seven `subscribe` error callbacks that
  feed it were annotated `CwError` too; at runtime they are all
  `HttpErrorResponse`. Replaced with a structural type naming the two fields
  the method actually reads.
- The `required` flag on text and date-time condition inputs came from
  `paramDef.inputType.dataType?.['minLength'] > 0`. `minLength` is a
  *constraint* on the data type, not a property of it, so that read has always
  been `undefined > 0` — false since it was written. Kept false deliberately,
  behind a named helper that explains why: reading the real constraint would
  start enforcing required on those fields, which is a UI change and wants its
  own issue.
- `isPersisted()` is now a `this`-typed predicate (`this is this & { key:
  string }`), so `if (model.isPersisted())` narrows `key` for the callers that
  go on to build a URL from it.
- The `patch*` methods in the container passed an unsaved rule's `null` key to
  services that interpolate it into a path or send it as `owningRule`. Those
  requests could only fail; they now short-circuit with a log line. Unreachable
  in practice — an action or group only has a key once its rule was saved — but
  the code no longer pretends otherwise.
- `patchCondition` had the same eight-line "add condition and record its key"
  block twice, and the copy inside the create-group callback read a key that
  the create had only just assigned to the captured model. Extracted, and it
  now reads the key off the create's own emitted value.
- `apiKey` on `DotVisitorsLocationContainerComponent` was declared, never
  assigned and never read. Gone.

Verified:

    tsc -p tsconfig.lib.json    0   (was 276)
    tsc -p tsconfig.spec.json   0   (was 286)
    nx run dot-rules:test       12/12, exit 0
    nx run dot-rules:lint       exit 0
    nx run dotcms-ui:build      exit 0

One limit worth stating: `dot-rules` has no `build` target, so `tsc -p` is its
only gate, and `tsc -p` does not check templates. The `dotcms-ui` build does
compile these sources — the chunk carrying `dot-rule-engine` is in `dist` — so
the templates bind cleanly, but that app sets `strictTemplates: false`
(#35930), so nothing checks these templates *strictly* yet. Neither dependent
(`dotcms-ui`, `edit-ema/portlet`) imports a single symbol from this library
beyond `DotRulesModule`, so widening its public types has no blast radius.

Closes #35957

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tgrew (#35975)

`libs/portlets/edit-ema/portlet` has only `lint` and `test` targets, and
`test` is transpile-only, so nothing has type-checked this library. Under its
*current* tsconfig — four of the six strict flags, `strict` and
`noPropertyAccessFromIndexSignature` still off — it reported 188 lib and 22
spec errors. All of it pre-existing. Both configs are now at 0, before any
strict flag is added.

The lib figure was almost entirely two config defects:

`tsconfig.lib.json` compiled `src/lib/shared/mocks.ts` as a production source.
It is 1061 lines of test fixtures, imported by 18 spec files and no production
file, not exported from `src/index.ts`, and reachable by nothing outside this
project — but its name does not end in `.spec.ts`, so the exclude globs never
caught it. It pulls in `@dotcms/utils-testing`, which has no jest globals under
this config's `types: []`: 29 of the reported errors came from there. That is
the fourth instance of this defect class in the epic, after the two in the
previous commit.

The other 7 were `src/lib/store/features/editor/save/withSave.ts`, which does
not compile: it imports `../../../../services/dot-page-api.service` (the
directory is `dot-page-api/`) and `../../load/withLoad`, which does not exist
anywhere in the repo. Nothing imports `withSave` — the only reference is a
comment in `withPageApi.ts` reading "This feature consolidates withLoad and
withSave for better organization", and `withPageApi` does carry `save`,
`saveStyleEditor` and the rest. It is a leftover from that consolidation that
was never deleted, and it has been broken since. Deleted.

On the spec side, six specs imported `StyleEditorFieldSchema`,
`StyleEditorRadioOptionObject` and `StyleEditorFormSchema` from
`@dotcms/uve`, which does not export them — they live in
`@dotcms/types/internal`, which is where the components under test already
import them from. Pointed the specs at the same place.

The remaining 14: eleven reads of `protected` component members, moved to the
bracket access this repo already uses for the same purpose, and one local
`workflowActionMock` whose `actionInputs[].body` was `[]` where
`DotCMSWorkflowInput.body` is a `Record<string, unknown>`. The tests pass that
value straight through and assert it comes back, so `{}` says the same thing.

    tsconfig.lib.json    188 → 0
    tsconfig.spec.json    22 → 0

Tests 1397 passed / 5 skipped across 64 suites, lint clean. The two missing
strict flags come next, on an honest baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…methods (#35975)

`withEditor` declared
`props: type<PageComputed & WorkflowLockComputed & ViewComputed>()` — three
whole interfaces, of which it reads four signals: `pageAsset`,
`pageVariantId`, `viewMode` and `$lockIsPageLocked`.

That constraint silently degraded the *host* store's type. `signalStore`
resolved `UVEStore` as `{ [x: string]: Function; uveStatus: Signal<...>; ... }`
— every method collapsed into a string index signature, while the signals
stayed named. Nothing failed, because the index signature accepts the calls;
the damage only becomes visible under `noPropertyAccessFromIndexSignature`,
where 43 ordinary calls like `uveStore.pageReload()` turn into TS4111. It is
also the reason `withWorkflow` reaches `pageReload()` through a type assertion,
which the store's own composition comment records.

Bisected to `WorkflowLockComputed` specifically: dropping the constraint
entirely resolves the store cleanly, and so does `props: type<PageComputed>()`
or `props: type<ViewComputed>()` — the same two interfaces `withWorkflow` and
`withView` already use this way without trouble. Naming the intersection as a
single interface does not help either, so it is the content, not the syntax.
Narrowing to what is actually consumed is what fixes it.

    tsconfig.lib.json with --strict --noPropertyAccessFromIndexSignature
      321 → 277 errors, of which TS4111 73 → 30

Type-only: the same 64 suites and 1397 tests pass, and both configs stay at 0
under the project's current flags.

While here: `PageSnapshot` was used in this file and never imported. It landed
during this change, but the class of problem is worth noting — an unresolved
type name makes the annotation `any`, so the three errors it was hiding only
appeared once the import was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ything (#35975)

`DotUveActionsHandlerService` declared its dispatch table as
`Record<DotCMSUVEAction, (payload: unknown) => void>` while thirteen of its
nineteen handlers take a specific payload. `strictFunctionTypes` rejects
assigning `(p: SetUrlPayload) => void` to `(p: unknown) => void`, and it is
right to — the map was asserting the opposite of what the handlers accept.

Replaced with a `UveActionPayloads` interface pairing each action with the
payload it actually carries, and a mapped `UveActionHandlers` type over it, so
each handler keeps its own parameter type. The six handlers that take no
payload need no entry of their own: a function may ignore arguments it is
passed. That leaves exactly one cast, at the dispatch site, which is where it
belongs — `action` and `payload` arrive from the SDK as an unrelated pair, so
nothing in this file can prove they match, and indexing the map yields the
union of every handler, which accepts only the intersection of their payloads.

The 30 remaining `TS4111` are ordinary index-signature reads — route `data`
and `params`, `dataset` (`DOMStringMap`), and `Record`-typed contentlet
fields — converted to bracket access. Done from the compiler's own
file/line/column output, verifying the property name sits at the reported
position and rewriting rightmost-first per line, with a check afterwards that
no nested or doubled brackets were produced.

    tsconfig.lib.json with --strict --noPropertyAccessFromIndexSignature
      277 → 234   (TS2418 13 → 0, TS4111 30 → 0)

64 suites, 1397 tests, lint clean; both configs still 0 under the project's
current flags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ee (#35975)

Three destructurings of values that can be absent, and ten fields declared
without an initialiser.

`$requestWithParams` in `withPage` guarded `requestMetadata()` and then re-read
it twice after the guard, which narrowing does not survive; `pageParams()` had
no guard at all, though a request can be in flight before the params land and
there are no variables to send without them. Both now read into locals and are
guarded once.

`toggleLock` destructured `$lockOptions()`, which is nullable. The template
only renders that button inside `@if ($lockOptions()?.canLock)`, so the guard
is a formality — but it is the honest way to say the options may be absent.

`DotEmaDialogComponent.dialogState` came from `toSignal(this.store.dialogState$)`,
i.e. `EditEmaDialogState | undefined`, and three call sites read through it
directly. `dialogState$` is a ComponentStore selector, which emits
synchronously on subscribe — that was already the assumption, so it now says
so with `requireSync: true`, which throws at construction if it ever stops
being true rather than surfacing as an undefined property downstream.

The ten `TS2564` are `@ViewChild` and `@Input` fields Angular assigns, taking
the `!` this codebase already uses for exactly those (`iframeComponent!` sat
two lines from three that lacked it), plus two where absence is a real state:
`favoritePage` is `contentlets[0]` of a possibly-empty list, which is what the
sibling `bookmarked` signal reflects, and `lastTemplate` is unset until the
user edits the layout.

That last one had a test relying on the defect. `initForceSaveOnLeave` called
`saveTemplate(this.lastTemplate)` with no guard, so leaving the page without
touching the layout passed `undefined` to a parameter typed
`DotTemplateDesigner` and on to the layout save API. Guarding it broke
"should save right away if we request page leave before the 5 secs", because
all five tests in that block called `templateBuilder.templateChange.emit()`
with no argument — `EventEmitter.emit()` takes an optional parameter, so it
compiled, and `lastTemplate` became `undefined`. The real
`TemplateBuilderComponent` always emits a designer object. Fixed the spec to
emit one rather than relax the guard; no assertion changed, and all five still
pass.

    tsconfig.lib.json with --strict --noPropertyAccessFromIndexSignature
      234 → 213

64 suites, 1397 tests, lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stated (#35975)

`lib/utils/index.ts` went from 22 strict-mode errors to 0, and most of them
traced to two preconditions the payload types did not express.

`insertContentletInContainer` needs the id of the contentlet it is writing.
`newContentletId` is optional on `ActionPayload` because most payloads describe
a position rather than a write — but every caller of the insert already
supplies it, and without one `undefined` reached `contentletsId.push()` and
from there the save request. Now `InsertActionPayload`.

`deleteContentletFromContainer` needs the contentlet it is removing; it filters
the container's ids by `contentlet.identifier`. `ClientData.contentlet` is
optional because a drop onto an empty container has none, so this used to throw
a `TypeError` on that path. Now `ContentletActionPayload`, and of its two call
sites one proves it by construction — `createDeletePayload` builds the
contentlet — while the other guards, because there the payload arrives through
the SDK's bounds message where the field really is optional.

Also in that file:

- `insertPositionedContentletInContainer` looked up its insertion point with
  `indexOf(contentlet.identifier)`. With no pivot that was `indexOf(undefined)`
  → -1 → fall through to append. Same behaviour, but it now says "no pivot
  means append" instead of relying on that.
- `removeUndefinedValues` was declared to take `DotPageAssetParams`, which
  neither of its two callers passes. It is a generic strip-undefined helper and
  is now typed as one.
- `getDragItemData` read `type`/`item` off a `DOMStringMap` and then
  `contentType.baseType` / `contentlet.contentType`, all three optional. A
  malformed dataset already ended in the `catch` that returns null; these paths
  now reach the same conclusion without the throw.
- `mapContainerStructureToDotContainerMap` is where the SDK's `DotCMSContainer`
  meets the legacy `DotContainer`. They differ in exactly one field — `source`,
  `string` versus the `CONTAINER_SOURCE` enum — so the cast is documented at
  that one boundary rather than spread over the map.

A note on the numbers, because they do not move monotonically: typing the two
implicitly-`any` parameters (`normalizeQueryParams`, `convertClientParamsToPageParams`)
took the total from 213 to 239 before the rest brought it to 194. Those 26 were
real errors their callers had been hiding behind `any`, 17 of them
index-signature reads. A count that rises when an `any` is removed is the
honest measurement.

    tsconfig.lib.json with --strict --noPropertyAccessFromIndexSignature
      213 → 194
    tsconfig.lib.json / tsconfig.spec.json under the project's current flags
      0 / 0, unchanged

64 suites, 1397 tests, lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three files conflicted. `main` had added a whole new Experiments portlet, which
is why GitHub listed many more — git resolved the rest on its own.

- `edit-content/.../dot-edit-content-field.constant.ts` — both sides kept.
  `main` already carries `1995adcfac refactor(dotcms-models): enable TypeScript
  strict mode #35934`, an earlier project of this same epic merged separately,
  which turned `ComponentStatus` from an `enum` into an `as const` object. So
  the key had to become `typeof ComponentStatus.ERROR`, from `main`, while the
  value keeps this branch's `PrincipalConfiguration & { subtitle: string }`.
- `dot-experiments-list.component.ts` — took `main`. It removed the
  create-sidebar mechanism from that component, leaving the `componentRef`
  field with no users.
- `sdk/vue/.../DotImage.vue` — took `main`, which wraps the image in an anchor
  (#36998). This branch differed only in indentation.

What the merge cost, since `main`'s new code was written without the flags this
branch turns on. Four projects that were at 0 had to be brought back:

- `dot-experiments` — `main` moved the old list component under `old/`. Git put
  this branch's `!` on the new path and the `old/` copy arrived without it.
- `dot-experiments` — a new spec passes `Partial<C>` where Spectator's `props`
  wants `InferInputSignals<C>`; same cast the sibling specs here already use.
- `content-drive` — four new mocks omit `_body`, which
  `DotAjaxActionResponseView` declares, and one loop widened three literal ids
  with `as string[]` where the `Map` is keyed by
  `DotActionCenterQuickActionId`. `as const` is the fix: the ids were already
  literals, but a mutable array literal widens its elements.
- `dot-analytics` — `status = ComponentStatus.LOADED` now infers the literal
  `'LOADED'`, so the tests that set LOADING and ERROR needed the field
  annotated with the union.

Verified by measuring the same configs in a throwaway worktree at plain
`origin/main`, to separate this branch's work from what was already broken:

                              origin/main    this branch
    dot-agents lib                   634              2
    dot-analytics spec                19              0
    sdk/react spec                     7              7
    libs/dotcms lib / spec        17 / 36        17 / 36

Nothing regressed. The counts that stayed put are the documented measurement
traps — `virtual:sdk-version` in the three SDK consumers, `TS2688` on the
missing `@types/jasmine` in `dotcms-js`, and `libs/dotcms`, which no epic issue
covers yet.

Every project this branch has closed is back at 0 on both configs, including
`edit-ema/portlet` and `dot-rules`. Tests green across `dot-rules`,
`edit-content`, `dot-experiments`, `content-drive` and `dot-analytics`:
3805 passing, 2 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nicobytes and others added 4 commits August 18, 2026 21:05
…nnotation (#35975)

`inline-edit.service.ts` declared `interface Window { tinymce: any }` in a
`declare global` block at the top of the file, and that one `any` made the whole
service unfixable from the outside: an intersection with `any` is `any`, so
`tinymce.init(...).then(([ed]) => ...)` inferred `ed` as `any` no matter what
types the call site declared. I started by hand-writing an interface for the
slice of TinyMCE this service touches, then found that TinyMCE ships its own
types and `libs/edit-content` already imports them. Dropped the approximation
and typed the augmentation with the real `TinyMCE` and `Editor`.

Three things that were not typing problems:

- `injectInlineEdit` and `removeInlineEdit` both dereferenced
  `iframe.nativeElement.contentDocument`, which is `null` until the iframe has a
  document of its own.
- `isInMultiplePages` read `.dataset` off the result of a `closest()` that finds
  nothing when the field sits outside any contentlet.
- `handleInlineEdit` wrote its argument into a nullable signal and then read it
  back to pass it on. It now passes the argument, which cannot be null.

The rest of this commit is one cascade worth describing, because it started as a
`TS2769` "no overload matches this call" against `signalStore` and its 45
overloads, which reads like an arity problem and is not one.

Typing `normalizeQueryParams` in the previous commit stopped it returning `any`.
The real type — `Record<string, string | undefined>` — then propagated, and five
declarations that had been describing the same data as `Record<string, string>`
turned out to be wrong: `$requestWithParams` in `withPage`, `withPageApi` and
`withWorkflow`, the `variables` parameter of `getGraphQLPage`, and
`pageFriendlyParams`. One of those flowed into the store composition, where the
mismatch surfaced as the overload failure rather than at the declaration.

Widened rather than defaulted, because the optional values were never sent
anyway: `getGraphQLPage` posts its argument as a JSON body, and
`JSON.stringify` omits keys whose value is `undefined`; `pageFriendlyParams`
goes to Angular's `queryParams`, which does the same. An absent `mode` or
`variantName` has never reached the wire. The type just did not say so.

    tsconfig.lib.json with --strict --noPropertyAccessFromIndexSignature
      194 → 174
    tsconfig.lib.json / tsconfig.spec.json under the project's current flags
      0 / 0

64 suites, 1397 tests, lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e wrong value (#35975)

`withPageApi.ts` goes from 15 strict-mode errors to 0. Most of them came from
one block repeated four times:

    const pageRequest = !deps.requestMetadata()
        ? dotPageApiService.get(store.pageParams())
        : dotPageApiService.getGraphQLPage(deps.$requestWithParams())

Each copy tests `requestMetadata()` and then passes `$requestWithParams()` —
two different nullable reads, which the compiler cannot correlate. Branching on
the value that is actually used is the same decision, because
`$requestWithParams` returns null exactly when there is no request metadata,
and it is the one narrowing can follow. `store.pageParams()` gets the same
treatment, with an `EMPTY` for the case where neither identifies a page; that
request could only have failed.

Deliberately not extracted into a helper. It should be — the duplication is the
real problem — but this is the portlet's load and save path and this is a typing
pass. Noted in a comment at the first site.

`dotPageLayoutService.save` read `page.identifier` with no check that a page had
loaded, and `DotTemplateDesigner.title` was `string | undefined` while three
call sites deliberately send `title: null`, which is how the layout endpoint
distinguishes "save this as a page layout" from "save it as a named template".
Widened the model; blast radius measured across seven strict projects, zero.

I got the guard for that save wrong first, and two tests caught it. I had
written `!template?.theme`, reading an empty theme as a missing one.
`mockDotTemplate()` does not omit `theme` — it sets `theme: ''`, and
`DotCMSTemplate.theme` is a required `string` whose empty value is what a page
whose template has no theme assigned actually carries. The guard refused to save
those pages' layouts, where the old code sent `themeId: ''` through. What made
`theme` read as `string | undefined` was never the field; it was the `?.` on
`pageAsset()`. Three separate optional reads of one asset narrow none of them,
so the asset is now read once and guarded once — `page`, `layout` and `template`
are all required on `DotCMSPageAsset` — and the empty theme travels as before.

    tsconfig.lib.json with --strict --noPropertyAccessFromIndexSignature
      174 → 157
    tsconfig.lib.json / tsconfig.spec.json under the project's current flags
      0 / 0

64 suites, 1397 tests, lint clean on this project and on dotcms-models.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ype that rejects it (#35975)

`withEditor.ts` goes from 13 strict-mode errors to 0.

`[RUNNING, SCHEDULED].includes(experiment?.status)` appeared inline in three
places — `computeCanEditPage` in utils and twice here. `includes` rejects an
optional status, correctly: "no experiment" is not "a blocking experiment".
Extracted as `isExperimentBlockingEdit`.

Three signatures that were wrong rather than merely strict:

- `buildIframeURL({ url, params, dotCMSHost })` had no parameter type at all,
  so all three destructured names were implicitly `any`. Typed with
  `DotPageAssetParams`, which is what `getFullPageURL` already requires of the
  same value.
- `getPersonalization(persona: DotCMSViewAsPersona)` declared its parameter
  required while the first branch of its body is the "no persona" case, and its
  caller passes `viewAs?.persona`.
- `PageData` declared `id`, `languageId` and `personaTag` required, but it is
  derived from `pageAsset()`, which is null until a page loads.

Two judgement calls worth flagging:

`getPageSavePayload` built its container as
`positionPayload.container ? { ...container, contentletsId } : null`. `container`
is required on `PositionPayload`, and an `ActionPayload` without one is not
constructible, so that null branch was guarding against a shape the parameter
type rules out. Removed.

The `?? ''` fallbacks in `getPageSavePayload` and `getCurrentTreeNode` I am less
happy with. Both read the now-optional `PageData` fields, both are reached only
from SDK messages that a rendered page produces, and both replace an
`undefined.toString()` that is what happened before if they ever were reached.
A guard would be better, but both methods return non-optional types
(`ActionPayload`, `DotTreeNode`) and there is no honest value to return without
a page. Left as a fallback with the reasoning in a comment; an explicit throw
would be a defensible alternative.

Also: `numberContents > 1` on an optional count (`undefined > 1` and `0 > 1`
agree, so `?? 0` preserves it), `$styleSchema` declared `StyleEditorFormSchema`
while its `find` can miss and its consumer already expects `| undefined`,
`$pageRender` declared `string` over an optional `rendered`, and
`getCurrentTreeNode` called `findIndex` on `contentletsId`, which is optional
because a container the SDK reports before any content has been added has none.

    tsconfig.lib.json with --strict --noPropertyAccessFromIndexSignature
      157 → 144
    tsconfig.lib.json / tsconfig.spec.json under the project's current flags
      0 / 0

64 suites, 1397 tests, lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nnel (#35975)

`edit-ema-editor.component.ts` goes from 21 strict-mode errors to 0. Two of them
were real defects in the file-drop path.

`DotTempFileUploadService.upload` is typed
`Observable<DotCMSTempFile[] | string>`, and that union is not sloppiness: its
`handleError` does `map((err) => err.status.toString())`, so a failed upload
emits the HTTP status as a *string* on the success channel. The caller here
destructured every emission as `DotCMSTempFile[]`. On the string arm that took
the string's first *character*, read `image` off it as undefined, and fell into
the "not an image" branch — the right message, by accident. Now it checks.

I started writing an overload on the service instead (`File` →
`DotCMSTempFile[]`, `string` → `string`) and stopped: it would have been wrong.
The string arm is the error path and is reachable for both input forms.

`handleFileUpload` takes the drag item from `editorDragItem()`, which only a
palette drag sets. A file dragged straight from the desktop arrives with null,
and at the end of the pipe `placeItem(payload, null)` reads
`dragItem.draggedPayload` — a `TypeError`. Guarded: the asset is still uploaded
and published, only the placement is skipped. That needs its own issue; the
intended behaviour for a desktop file drop is not something I can infer from
here.

The rest were signatures describing less than their bodies handle.
`isSamePageNavigation` and `getTargetUrl` declared required parameters whose
first lines are the absent case. `DialogAction.actionPayload` is optional — the
model documents that it is missing when the dialog is opened outside a page
asset — and four handlers dereferenced it; they now return early, which is the
only thing they could do without a container to insert into.
`searchParams.get()` returns null for an absent parameter, which `pageLoad` was
reading as a language rather than as "keep the current one".

Also removed a `? : null` on the move path's container and a redundant
`canReinsertRelative` flag I had introduced a moment earlier: the guard now
checks the three things a move actually needs — origin container, pivot
contentlet, insert position — in one place.

    tsconfig.lib.json with --strict --noPropertyAccessFromIndexSignature
      144 → 119
    tsconfig.lib.json / tsconfig.spec.json under the project's current flags
      0 / 0

64 suites, 1397 tests, lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area : Documentation PR changes documentation files Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries

Projects

Status: No status

3 participants